From c66c7a7feebc5617f1ae01e3f9dee405094654e4 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Fri, 16 Oct 2026 23:02:30 +0200 Subject: [PATCH 1/4] =?UTF-8?q?GDScript-Hervorhebung=20f=C3=BCr=20Codebl?= =?UTF-8?q?=C3=B6cke=20und=20=C3=9Cbungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codeblöcke mit `gdscript` (auch `gd`) bekommen Farben für Annotationen, Node-Pfade, StringName- und NodePath-Literale und das Label „GDScript“, in Lektionen wie in Übungen. Lücken und Korrekturen vergleichen GDScript wie Python ('a' = "a"). Ausgeführt wird es nirgends, wie Kotlin. Das Handbuch nennt die neue Sprache. Co-Authored-By: Claude Opus 5.5 --- HANDBUCH.md | 2 +- src/engine/answers.test.ts | 5 +++++ src/engine/answers.ts | 3 ++- src/engine/highlight.ts | 9 ++++++++- src/exercises/basic.ts | 2 +- tooling/content.test.ts | 11 +++++++++++ tooling/markdown.ts | 2 +- 7 files changed, 29 insertions(+), 5 deletions(-) diff --git a/HANDBUCH.md b/HANDBUCH.md index 2a5d608..e408408 100644 --- a/HANDBUCH.md +++ b/HANDBUCH.md @@ -188,7 +188,7 @@ Richtwert: 3–6 Callouts pro Lektion, davon mindestens ein `INTERVIEW` und ein ### Codeblöcke -Codeblöcke immer mit Sprache angeben. Unterstützt werden `kotlin`, `rust`, `python`, `bash`/`sh`, `console`, `yaml`, `toml`, `json`, `sql`, `postgres` (auch `postgresql`, `pgsql`), `xml`, `dockerfile`, `ini` (auch für systemd-Units), `diff`, `excel` (Formeln), `powerquery` (Power Query M, auch `m`), `csv`, `typescript` (auch `ts`, `tsx`), `javascript` (auch `js`, `jsx`) und `text`. +Codeblöcke immer mit Sprache angeben. Unterstützt werden `kotlin`, `rust`, `python`, `bash`/`sh`, `console`, `yaml`, `toml`, `json`, `sql`, `postgres` (auch `postgresql`, `pgsql`), `xml`, `dockerfile`, `ini` (auch für systemd-Units), `diff`, `excel` (Formeln), `powerquery` (Power Query M, auch `m`), `csv`, `typescript` (auch `ts`, `tsx`), `javascript` (auch `js`, `jsx`), `gdscript` (auch `gd`) und `text`. GDScript wird nur hervorgehoben und läuft nirgends, wie Kotlin; in Lücken und Korrekturen wird es wie Python verglichen (`'a'` = `"a"`). | Info-String | Wirkung | | --- | --- | diff --git a/src/engine/answers.test.ts b/src/engine/answers.test.ts index 50a3c0e..923e80d 100644 --- a/src/engine/answers.test.ts +++ b/src/engine/answers.test.ts @@ -54,6 +54,11 @@ describe('Answer checking per language', () => { expect(tokens(`print('a')`, 'python')).not.toEqual(tokens(`print('b')`, 'python')); expect(tokens(`f'{x}'`, 'python')).toEqual(tokens(`f"{x}"`, 'python')); }); + it('compares GDScript like Python: quote styles equal, := kept as one operator', () => { + expect(tokens(`animation_player.play(&'attack')`, 'gdscript')).toEqual(tokens(`animation_player.play(&"attack")`, 'gd')); + expect(tokens(`var speed := 90.0`, 'gdscript')).toEqual(['var', 'speed', ':=', '90.0']); + expect(tokens(`hitbox.set_deferred("monitoring", true)`, 'gdscript')).not.toEqual(tokens(`hitbox.set_deferred("monitoring", false)`, 'gdscript')); + }); it('treats TypeScript quote styles and line-final semicolons as equal', () => { const answer = `await user.click(screen.getByRole('button', { name: 'Buchen' }));`; expect(tokens(answer, 'typescript')).toEqual(tokens(`await user.click(screen.getByRole("button", { name: \`Buchen\` }))`, 'typescript')); diff --git a/src/engine/answers.ts b/src/engine/answers.ts index 87a3e8f..631bbd6 100644 --- a/src/engine/answers.ts +++ b/src/engine/answers.ts @@ -15,7 +15,8 @@ const family = (lang: Lang) => { const l = lang.toLowerCase(); if (['kotlin', 'kt', 'kts', 'gradle', 'java', 'groovy'].includes(l)) return 'kotlin'; if (['rust', 'rs'].includes(l)) return 'rust'; - if (['python', 'py', 'python3', 'pycon'].includes(l)) return 'python'; + // GDScript tokenises like Python: # comments, 'a' = "a", := and ->. + if (['python', 'py', 'python3', 'pycon', 'gdscript', 'gd'].includes(l)) return 'python'; if (['bash', 'sh', 'shell', 'zsh', 'fish', 'console', 'terminal'].includes(l)) return 'shell'; if (['excel', 'xlsx', 'formula'].includes(l)) return 'excel'; if (['sql', 'sqlite', 'postgresql', 'postgres', 'pgsql', 'plpgsql'].includes(l)) return 'sql'; diff --git a/src/engine/highlight.ts b/src/engine/highlight.ts index 2d477a9..485a39d 100644 --- a/src/engine/highlight.ts +++ b/src/engine/highlight.ts @@ -28,6 +28,13 @@ const LANGUAGES: Record = { builtins: words(`print len range enumerate zip map filter sorted reversed sum min max abs any all open isinstance issubclass type int float str bool list dict set tuple frozenset bytes object super iter next repr hash id input round divmod getattr setattr hasattr vars dir format callable`), types: true, calls: true, }, + // GDScript (Godot 4): @annotations, $Node and %Unique paths, &"StringName", ^"NodePath" and r"raw" strings. + gdscript: { + rules: [hashComment, [/[&^r]?(?:"""[\s\S]*?(?:"""|$)|'''[\s\S]*?(?:'''|$)|"(?:\\.|[^"\\\n])*"?|'(?:\\.|[^'\\\n])*'?)/y, 'string'], [/@[A-Za-z_]\w*/y, 'annotation'], [/[$%][A-Za-z_][\w/]*/y, 'variable'], number], + keywords: words(`if elif else for while match when break continue pass return class class_name extends is in as self signal func static const enum var await preload super true false null not and or void PI TAU INF NAN breakpoint`), + builtins: words(`print push_warning push_error len range str int float bool abs absf absi clamp clampf clampi lerp lerpf min max minf maxf sign signf move_toward snapped randf randi randf_range randi_range is_instance_valid load typeof deg_to_rad rad_to_deg`), + types: true, calls: true, + }, shell: { rules: [hashComment, [/'[^']*'?/y, 'string'], [/"(?:\\.|[^"\\])*"?/y, 'string'], [/\$\{[^}\n]*\}?|\$[A-Za-z_]\w*|\$[0-9@#?$!*-]/y, 'variable'], [/(?<=\s)--?[A-Za-z][\w-]*/y, 'option']], keywords: words(`if then else elif fi for while until do done case esac in function return local export readonly declare set unset shift exit trap source select break continue`), @@ -100,7 +107,7 @@ const LANGUAGES: Record = { }; LANGUAGES.javascript = LANGUAGES.typescript; const ALIASES: Record = { - kt: 'kotlin', kts: 'kotlin', gradle: 'kotlin', java: 'kotlin', groovy: 'kotlin', rs: 'rust', py: 'python', python3: 'python', + kt: 'kotlin', kts: 'kotlin', gradle: 'kotlin', java: 'kotlin', groovy: 'kotlin', rs: 'rust', py: 'python', python3: 'python', gd: 'gdscript', bash: 'shell', sh: 'shell', zsh: 'shell', fish: 'shell', terminal: 'shell', yml: 'yaml', jsonc: 'json', docker: 'dockerfile', containerfile: 'dockerfile', systemd: 'ini', service: 'ini', conf: 'ini', cfg: 'ini', html: 'xml', svg: 'xml', pycon: 'python', postgresql: 'postgres', pgsql: 'postgres', plpgsql: 'postgres', diff --git a/src/exercises/basic.ts b/src/exercises/basic.ts index c5b8a64..0288d86 100644 --- a/src/exercises/basic.ts +++ b/src/exercises/basic.ts @@ -7,7 +7,7 @@ import { failOnce, type ExerciseRenderer } from './types.ts'; export const codeCard = (code: string, lang: string, label: string, gaps = false) => `
${escape(label)}
${gaps ? highlightWithGaps(code, lang) : highlight(code, lang)}
`; -export const LANG_NAMES: Record = { kotlin: 'Kotlin', rust: 'Rust', python: 'Python', bash: 'Bash', sh: 'Shell', shell: 'Shell', console: 'Terminal', yaml: 'YAML', toml: 'TOML', json: 'JSON', sql: 'SQL', postgres: 'PostgreSQL', dockerfile: 'Dockerfile', ini: 'Konfiguration', xml: 'XML', gradle: 'Gradle', kts: 'Gradle Kotlin DSL', excel: 'Excel-Formel', powerquery: 'Power Query (M)', m: 'Power Query (M)', csv: 'CSV', prose: 'Text', harlowe: 'Harlowe (Twine)', ablauf: 'Ablauf' }; +export const LANG_NAMES: Record = { kotlin: 'Kotlin', rust: 'Rust', python: 'Python', bash: 'Bash', sh: 'Shell', shell: 'Shell', console: 'Terminal', yaml: 'YAML', toml: 'TOML', json: 'JSON', sql: 'SQL', postgres: 'PostgreSQL', dockerfile: 'Dockerfile', ini: 'Konfiguration', xml: 'XML', gradle: 'Gradle', kts: 'Gradle Kotlin DSL', excel: 'Excel-Formel', powerquery: 'Power Query (M)', m: 'Power Query (M)', csv: 'CSV', prose: 'Text', harlowe: 'Harlowe (Twine)', ablauf: 'Ablauf', gdscript: 'GDScript', gd: 'GDScript' }; export const langName = (lang: string) => LANG_NAMES[lang] ?? (lang ? lang.toUpperCase() : 'Code'); export const gap: ExerciseRenderer = (exercise, ctx) => { diff --git a/tooling/content.test.ts b/tooling/content.test.ts index 645c79e..acdede1 100644 --- a/tooling/content.test.ts +++ b/tooling/content.test.ts @@ -119,6 +119,17 @@ describe('content', () => { expect(messages.some((m) => /als "vm" markiert, enthält aber keinen Befehl/.test(m))).toBe(true); } finally { rmSync(root, { recursive: true, force: true }); } }); + it('highlights GDScript without making it runnable', () => { + const html = codeBlock('@export var windup := 12 # Frames\nfunc _ready() -> void:\n\t$Hitbox.monitoring = false\n\tplay(&"attack")', 'gdscript run'); + expect(html).toContain('GDScript'); + expect(html).not.toContain('data-run'); + expect(html).toContain('@export'); + expect(html).toContain('func'); + expect(html).toContain('$Hitbox'); + expect(html).toContain('&"attack"'); + expect(html).toContain('# Frames'); + expect(codeBlock('pass', 'gd')).toContain('GDScript'); + }); it('does not offer deliberately invalid Rust snippets as executable playground examples', () => { expect(codeBlock('fn main() { invalid }', 'rust nocheck')).not.toContain('data-playground'); expect(codeBlock('fn main() {}', 'rust')).toContain('data-playground="rust"'); diff --git a/tooling/markdown.ts b/tooling/markdown.ts index 33d472a..2ed4827 100644 --- a/tooling/markdown.ts +++ b/tooling/markdown.ts @@ -14,7 +14,7 @@ export const slug = (value: string) => value.toLowerCase() const LABELS: Record = { kotlin: 'Kotlin', rust: 'Rust', python: 'Python', shell: 'Shell', console: 'Terminal', yaml: 'YAML', toml: 'TOML', json: 'JSON', sql: 'SQL', postgres: 'PostgreSQL', xml: 'XML', dockerfile: 'Dockerfile', ini: 'Konfiguration', diff: 'Diff', text: 'Text', - excel: 'Excel-Formel', powerquery: 'Power Query (M)', csv: 'CSV', typescript: 'TypeScript', javascript: 'JavaScript', + excel: 'Excel-Formel', powerquery: 'Power Query (M)', csv: 'CSV', typescript: 'TypeScript', javascript: 'JavaScript', gdscript: 'GDScript', }; /** Render a fenced code block; shared by lessons and exercise code. */ From 544a747c12e269d277855a1ff42d77788e748655 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Fri, 16 Oct 2026 23:02:30 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Inhaltsverzeichnis=20einer=20Lektion=20beko?= =?UTF-8?q?mmt=20keine=20=C3=9Cberschriften=20sp=C3=A4terer=20Bl=C3=B6cke?= =?UTF-8?q?=20mehr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMarkdown().document() gab sein toc-Array zurück, und spätere block()-Aufrufe (Übungstexte, Projekt-Briefings) schrieben weiter hinein. Dadurch standen die Überschriften eines Projekt-Briefings im Inhaltsverzeichnis des zuletzt geladenen Moduls, auf main etwa „Auftrag“ und „Was du lieferst“ in postgres/migrationen, mit Ankern, die es in der Lektion nicht gibt. Co-Authored-By: Claude Opus 5.5 --- tooling/content.test.ts | 10 +++++++++- tooling/markdown.ts | 5 ++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/tooling/content.test.ts b/tooling/content.test.ts index acdede1..d3dadd8 100644 --- a/tooling/content.test.ts +++ b/tooling/content.test.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { formatIssues, lessonBlocks, loadContent } from './content.ts'; -import { codeBlock } from './markdown.ts'; +import { codeBlock, createMarkdown } from './markdown.ts'; describe('content', () => { it('fixture area loads without errors and covers every exercise type', () => { @@ -119,6 +119,14 @@ describe('content', () => { expect(messages.some((m) => /als "vm" markiert, enthält aber keinen Befehl/.test(m))).toBe(true); } finally { rmSync(root, { recursive: true, force: true }); } }); + it('keeps headings of later blocks out of a lesson\'s table of contents', () => { + const md = createMarkdown(); + const { toc } = md.document('## Erster Abschnitt\n\nText\n\n## Zweiter Abschnitt\n'); + md.block('## Auftrag\n\nBrief eines Projekts'); + expect(toc.map((entry) => entry.title)).toEqual(['Erster Abschnitt', 'Zweiter Abschnitt']); + const module = loadContent(undefined, join(import.meta.dirname, 'fixtures')).areas.beispiel.modules['alle-typen']; + expect(module.toc.every((entry) => module.lesson.includes(`id="${entry.id}"`))).toBe(true); + }); it('highlights GDScript without making it runnable', () => { const html = codeBlock('@export var windup := 12 # Frames\nfunc _ready() -> void:\n\t$Hitbox.monitoring = false\n\tplay(&"attack")', 'gdscript run'); expect(html).toContain('GDScript'); diff --git a/tooling/markdown.ts b/tooling/markdown.ts index 2ed4827..901c2e2 100644 --- a/tooling/markdown.ts +++ b/tooling/markdown.ts @@ -93,7 +93,10 @@ export function createMarkdown() { toc = []; used.clear(); const html = marked.parse(callouts(source), { async: false }) as string; - return { html, toc }; + const result = { html, toc }; + // Later block() calls (exercise prompts, project briefs) must not add their headings to this lesson's table of contents. + toc = []; + return result; }, block(source: string): string { return marked.parse(callouts(source), { async: false }) as string; From 6728cbd3afc600b7466cc5d0bb3af36ddca53f56 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Fri, 16 Oct 2026 23:02:42 +0200 Subject: [PATCH 3/4] Neuer Bereich Combat Design & Game Feel mit Godot-4-Testszene (LRN-26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 14 Module in vier Tracks, 179 Übungen (choice 27, order 16, output 42, gap 22, bug 26, explain 18, practice 28), 44 Interview-Karten, vier Projekte mit Abschlussprojekt, 47 Glossarbegriffe, Spickzettel und Berufsseite. content:check ohne Hinweis, verify mit 29 Prüfungen. - Unterwegs (Handy): Spielgefühl, Eingabe und Puffer, Angriffsphasen, Hitboxen, Ausweichen, Gegnermuster, große Gegner, Trefferfeedback, Frust-Diagnose, Vorgaben für Coding-Agenten. Analyse an verlinkten Clips (Sakurai, GDC) und kleine Python-Modelle, die im Browser laufen. - Am PC: Godot-Start (Installation unter CachyOS, Szenen, Bewegung mit Input.get_vector, AnimationPlayer mit Hitbox-Timing, Input Map, Pixel-perfect), Testszene Angriff, Testszene Ausweichen und Trainingsgegner, Tuning als Experiment. - Jedes Modul ist im Track-Titel und mit einem Hinweis am Anfang der Lektion als „Unterwegs“ oder „Am PC“ markiert. Ein Modulfeld dafür bringt LRN-29; danach auf `place` umstellen. - Der GDScript-Code der Testszene ist mit Godot 4.7.2 headless geprüft, jedes zitierte Log stammt aus einem echten Lauf. - KI-Regel: Code und technisches Gerüst kommen aus dem Kurs, jede Kampfmechanik und jeder Wert bleiben bei Steven. Designaufgaben sind Selbstkontrolle ohne Musterwert. - Farbe #FF4D6D, order 11. Co-Authored-By: Claude Opus 5.5 --- HANDBUCH.md | 2 +- README.md | 2 +- content/combat/area.yaml | 33 + content/combat/career.md | 101 ++ content/combat/cheatsheet.md | 126 +++ content/combat/glossary.yaml | 95 ++ content/combat/interview.yaml | 307 ++++++ content/combat/modules/angriffsphasen.yaml | 554 +++++++++++ content/combat/modules/ausweichen.yaml | 503 ++++++++++ content/combat/modules/eingabe.yaml | 594 ++++++++++++ content/combat/modules/frust-diagnose.yaml | 493 ++++++++++ content/combat/modules/gegnermuster.yaml | 453 +++++++++ content/combat/modules/godot-start.yaml | 502 ++++++++++ content/combat/modules/grosse-gegner.yaml | 450 +++++++++ content/combat/modules/hitboxen.yaml | 501 ++++++++++ content/combat/modules/spielgefuehl.yaml | 500 ++++++++++ content/combat/modules/testszene-angriff.yaml | 887 ++++++++++++++++++ .../combat/modules/testszene-ausweichen.yaml | 733 +++++++++++++++ content/combat/modules/trefferfeedback.yaml | 536 +++++++++++ content/combat/modules/tuning.yaml | 649 +++++++++++++ content/combat/modules/vorgaben.yaml | 533 +++++++++++ content/combat/projects.yaml | 205 ++++ 22 files changed, 8757 insertions(+), 2 deletions(-) create mode 100644 content/combat/area.yaml create mode 100644 content/combat/career.md create mode 100644 content/combat/cheatsheet.md create mode 100644 content/combat/glossary.yaml create mode 100644 content/combat/interview.yaml create mode 100644 content/combat/modules/angriffsphasen.yaml create mode 100644 content/combat/modules/ausweichen.yaml create mode 100644 content/combat/modules/eingabe.yaml create mode 100644 content/combat/modules/frust-diagnose.yaml create mode 100644 content/combat/modules/gegnermuster.yaml create mode 100644 content/combat/modules/godot-start.yaml create mode 100644 content/combat/modules/grosse-gegner.yaml create mode 100644 content/combat/modules/hitboxen.yaml create mode 100644 content/combat/modules/spielgefuehl.yaml create mode 100644 content/combat/modules/testszene-angriff.yaml create mode 100644 content/combat/modules/testszene-ausweichen.yaml create mode 100644 content/combat/modules/trefferfeedback.yaml create mode 100644 content/combat/modules/tuning.yaml create mode 100644 content/combat/modules/vorgaben.yaml create mode 100644 content/combat/projects.yaml diff --git a/HANDBUCH.md b/HANDBUCH.md index e408408..aea57ce 100644 --- a/HANDBUCH.md +++ b/HANDBUCH.md @@ -71,7 +71,7 @@ Eine vollständige, minimale Vorlage mit jeder Übungsart liegt in [`tooling/fix 1. **Lehrplan entwerfen.** Schreibe zuerst die Liste der Module auf: 10–16 Module in 2–4 Tracks, vom Einstieg bis zum Profi-Niveau. Frage dich bei jedem Modul: *Was kann ich danach, was ich vorher nicht konnte?* Und: *Würde ein Arbeitgeber das erwarten?* 2. **Ordner anlegen:** `content//` mit `area.yaml` (Abschnitt 4). Die Ordner-ID ist kurz, klein und kebab-case, zum Beispiel `go`, `typescript`, `devops`. -3. **Farbe wählen.** Eine kräftige Farbe, auf der schwarzer Text gut lesbar ist (Kontrast ≥ 4,5 : 1 zu `#0B0B0B`). Vorhandene Farben nicht wiederholen: `#A98BFF` Kotlin, `#FF6A3D` Rust, `#FFD400` Linux, `#4D8BFF` Python, `#2BD97C` Automation, `#FF5CA8` Git, `#22D3EE` Data Analysis, `#C6F432` Testing, `#FF9F1C` PostgreSQL, `#F4A3FF` Erzählhandwerk. Gute freie Kandidaten: `#FFC6A8` (Apricot), `#B5E3FF` (Eisblau). +3. **Farbe wählen.** Eine kräftige Farbe, auf der schwarzer Text gut lesbar ist (Kontrast ≥ 4,5 : 1 zu `#0B0B0B`). Vorhandene Farben nicht wiederholen: `#A98BFF` Kotlin, `#FF6A3D` Rust, `#FFD400` Linux, `#4D8BFF` Python, `#2BD97C` Automation, `#FF5CA8` Git, `#22D3EE` Data Analysis, `#C6F432` Testing, `#FF9F1C` PostgreSQL, `#F4A3FF` Erzählhandwerk, `#FF4D6D` Combat Design. Gute freie Kandidaten: `#FFC6A8` (Apricot), `#B5E3FF` (Eisblau). 4. **Module schreiben** (Abschnitte 5–7). Ein Modul pro Datei, Dateiname = Modul-ID. 5. **Karten, Projekte, Glossar, Spickzettel, Berufsseite** ergänzen (Abschnitt 8). 6. **Prüfen:** `pnpm content:check `, dann `pnpm verify `, dann `pnpm test` und `pnpm build`. diff --git a/README.md b/README.md index 8ae717d..04f272a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # learn.kiumu.app -Private Lernplattform für Kotlin & Android, Rust, Linux, Python, Automation mit Python, Git, Data Analysis mit Excel, SQL und Python, Testing & Softwarequalität sowie SQL & PostgreSQL für Anwendungsentwickler. Live: [learn.kiumu.app](https://learn.kiumu.app) · Öffentliches Repository: [hernstev97/learning-platform](https://github.com/hernstev97/learning-platform) +Private Lernplattform für Kotlin & Android, Rust, Linux, Python, Automation mit Python, Git, Data Analysis mit Excel, SQL und Python, Testing & Softwarequalität, SQL & PostgreSQL für Anwendungsentwickler sowie Combat Design & Game Feel mit Godot 4. Live: [learn.kiumu.app](https://learn.kiumu.app) · Öffentliches Repository: [hernstev97/learning-platform](https://github.com/hernstev97/learning-platform) Die Plattform ist kein Nachschlagewerk. Jedes Modul besteht aus einer Lektion, die ein Denkmodell aufbaut und auf die offizielle Dokumentation verweist, und aus 10–14 Übungen in fester Reihenfolge: verstehen → vorhersagen → schreiben → Fehler finden → erklären → anwenden. Jeder Bereich hat Interview-Karteikarten mit Wiederholung, Projekte und ein Abschlussprojekt, das wie eine Take-Home-Aufgabe im Bewerbungsprozess geschnitten ist. diff --git a/content/combat/area.yaml b/content/combat/area.yaml new file mode 100644 index 0000000..19c831a --- /dev/null +++ b/content/combat/area.yaml @@ -0,0 +1,33 @@ +id: combat +title: Combat Design & Game Feel +short: Combat +tagline: Kampfgefühl in Frames messen, begründen und selbst abstimmen. +description: > + Eingabe und Puffer, Wind-up, aktive Phase und Erholung, Hit- und Hurtboxen, + Ausweichen, Gegnermuster, große Gegner und Trefferfeedback – erklärt an + veröffentlichten Spielen und kurzen Clips, die du unterwegs am Handy + analysierst. Am PC stimmst du eine kleine Top-down-Testszene in Godot 4 ab: + Hypothese, eine Größe ändern, vergleichen. Jede Designentscheidung triffst du + selbst; Code und technisches Gerüst darf eine KI liefern. +color: "#FF4D6D" +order: 11 +outcomes: + - Du beschreibst einen Angriff in **Frames** – Wind-up, aktive Phase, Erholung, Abbruchfenster, Puffer – und rechnest sie in Millisekunden um. + - Du erklärst, warum ein Treffer **fair oder unfair** wirkt, und trennst Schwierigkeit, mangelnde Lesbarkeit und technische Fehler wie verschobene Hitboxen oder verlorene Eingaben. + - Du analysierst **Ausweichen, Risiko und Belohnung** sowie Gegner- und Bossmuster veröffentlichter Spiele an kurzen Clips. + - Du setzt **Trefferfeedback** – Hitstop, Screenshake, Flash, Knockback, Sound – gezielt ein und kennst seine Kosten. + - Du richtest eine **Godot-4-Testszene** in der Draufsicht ein, mit bewegter Figur, `AnimationPlayer` mit Hitbox-Timing, Input Map und Pixel-perfect-Einstellungen. + - Du stimmst Kampfwerte als **Experiment** ab – Hypothese, eine Größe, Vergleichstest, Protokoll – und schreibst daraus eine Vorgabe, die ein Coding-Agent umsetzen kann. +tracks: + - title: "Unterwegs: Kampf verstehen" + description: Am Handy durcharbeitbar. Spielgefühl, Eingabe und Puffer, die Phasen eines Angriffs, Reichweite und Trefferzonen. + modules: [spielgefuehl, eingabe, angriffsphasen, hitboxen] + - title: "Unterwegs: Ausweichen und Gegner" + description: Am Handy durcharbeitbar. Ausweichen mit Risiko und Belohnung, Gegnermuster und Telegraphing, große Kreaturen als Bosse. + modules: [ausweichen, gegnermuster, grosse-gegner] + - title: "Unterwegs: Rückmeldung und Diagnose" + description: Am Handy durcharbeitbar. Trefferfeedback, die Frage „schwer, unfair oder kaputt?“ und Vorgaben, nach denen ein Coding-Agent baut. + modules: [trefferfeedback, frust-diagnose, vorgaben] + - title: "Am PC: Testszene in Godot" + description: Braucht Godot 4 am Rechner. Ein kurzer Godot-Start, dann eine Top-down-Testszene mit Angriff, Ausweichen und Trainingsgegnern, die du in Runden abstimmst. + modules: [godot-start, testszene-angriff, testszene-ausweichen, tuning] diff --git a/content/combat/career.md b/content/combat/career.md new file mode 100644 index 0000000..2b7de26 --- /dev/null +++ b/content/combat/career.md @@ -0,0 +1,101 @@ +## Combat Design im Beruf + +Combat Design ist die Arbeit an dem, was in einem Kampf Sekunde für Sekunde passiert: wie schnell ein Angriff kommt, wie lange er trifft, wann der Gegner verwundbar ist, wie sich ein Treffer anfühlt und warum ein Spieler verloren hat. In größeren Studios ist das eine eigene Rolle neben Level-, System- und Technical Design. In kleinen Teams und bei Solo-Projekten macht es dieselbe Person, die auch den Rest des Spiels entwirft. + +Für dich ist der Anlass dein eigenes Spiel. Diese Seite zeigt trotzdem, wie die Arbeit in Studios aussieht. Sie zeigt aber auch, welche Fähigkeiten dort gefragt sind und wie man sie belegt. Das hilft auch ohne Bewerbung: Wer weiß, woran Studios einen Combat Designer messen, weiß, worauf er beim eigenen Prototyp achten sollte. Die Stellenzitate stammen aus Anzeigen, die im Oktober 2026 online waren oder ausdrücklich als abgelaufen gekennzeichnet sind. Diese Seite ist keine Arbeitsmarktstudie. + +## Rollen und Jobtitel + +Titel bedeuten in jedem Studio etwas anderes. Lies die Aufgaben, nicht nur die Überschrift. + +| Rolle | Typische Titel | Alltag | Beispiel aus einer Anzeige | +| --- | --- | --- | --- | +| Combat Design | Combat Designer, Combat Game Designer | Angriffe, Gegner und Begegnungen entwerfen, bauen und abstimmen | Larian (Quebec): „Devise & take ownership of tactical, engaging combat encounters“ | +| Gameplay Design | Gameplay Designer, Game Designer | Spielmechaniken als Ganzes, oft mit Kampf als Teil | Ubisoft Montpellier: „Prototyper régulièrement et tester manette en main“, mit Blick auf „game feel“ | +| Technical Design | Technical Designer, Technical Game Designer | Mechaniken selbst im Editor bauen, Skripte, Werkzeuge, KI-Verhalten | Naughty Dog: „Prototyping, implementing, and owning gameplay scenarios … AI behaviors“ | +| KI- und Gegnerdesign | AI Game Designer, Enemy Designer | Verhalten von Gegnern mit Zustandsautomaten oder Behavior Trees | Massive (Malmö): „Implementing and refining high-quality combat NPC behaviors using node graphs and behavior trees“ | +| Kamera und Steuerung | Camera Designer, 3Cs Designer (Character, Camera, Controls) | Kamera, Steuerung, Bildschirmrückmeldung | People Can Fly: „camera shake and feedback that support the action without getting in the way“ | +| System- und Balancing-Design | System Designer, Balancing Designer | Werte, Schwierigkeitsstufen, Tabellen und Simulationen | Ubisoft Paris: Systeme für „enemies and bosses … difficulty tiers“, Werkzeuge „excel tables and simulators“ | + +Quellen: [Larian](https://jobs.lever.co/larian/ace7bbeb-31ff-44ea-991d-223ef9685284), [Ubisoft Montpellier](https://jobs.smartrecruiters.com/Ubisoft2/744000155797846-senior-game-designer-beyond-good-evil-2-h-f-nb), [Naughty Dog](https://job-boards.greenhouse.io/sonyinteractiveentertainmentglobal/jobs/5983976004), [Massive](https://jobs.smartrecruiters.com/Ubisoft2/744000127417779-senior-ai-game-designer-tom-clancy-s-the-division-3-), [People Can Fly](https://jobs.smartrecruiters.com/PeopleCanFly/744000153500485-concepteur-trice-de-jeu-senior-camera-et-3c-senior-game-designer-camera-and-3cs-remote), [Ubisoft Paris](https://jobs.smartrecruiters.com/Ubisoft2/744000155803319-system-designer-w-m-nb). + +Das Wort „Frame-Daten“ steht selten so in einer Anzeige. Am deutlichsten war eine inzwischen beendete Ausschreibung von CI Games für einen Senior Combat Designer: „Tune the combat experience at the micro level: animation lengths, I-frames, Hit boxes, and frame-data polish“ ([Remote Game Jobs, abgelaufen](https://remotegamejobs.com/jobs/ci-games-senior-combat-designer-remote-job-0081090d-9e28-452c-a2c4-8ba347942fbe)). Genau das übst du in diesem Bereich. + +## Was Studios erwarten + +Die Anzeigen wiederholen sich in sechs Punkten: + +- **Selbst im Editor bauen.** Fast jede Anzeige verlangt, dass du Mechaniken in der Engine umsetzt: Unreal mit Blueprints, Unity, eigene Engines wie Snowdrop. Mihir Sheth (Santa Monica Studio) sagte auf einem Devcom-Panel: „Implementation is extremely highly valued“, dazu Erfahrung mit Zustandsautomaten, KI-Systemen und Behavior Trees ([GamesIndustry.biz](https://www.gamesindustry.biz/articles/2020-08-21-how-do-you-make-hitting-something-fun)). +- **Zustandsautomaten und Behavior Trees.** Gegner werden fast überall so gebaut. Der Zustandsautomat deiner Testszene ist die einfachste Form davon. +- **Abstimmen in kleinen Schritten.** Jason de Heras (Respawn) sagte auf demselben Panel, man könne eine Woche lang Variablen einer Figur abstimmen. +- **Prototyp, Playtest, Iteration.** Massive verlangt „Continuously iterate on NPCs based on playtest feedback“, Scopely will Ergebnisse aus Daten, Spielerfeedback und eigenen Playtests ([Scopely](https://job-boards.greenhouse.io/scopely/jobs/5442575008)). +- **Dokumentation.** Larian nennt „a track record of effectively conveying design through clear documentation“. Eine Vorgabe, nach der ein anderer bauen kann, ist dieselbe Fähigkeit wie im Modul [Vorgaben für einen Coding-Agenten](/combat/vorgaben). +- **Zusammenarbeit.** Animation, VFX, Audio, Art und Programmierung kommen in fast jeder Anzeige vor. Ein Treffer fühlt sich erst gut an, wenn alle Gewerke zusammenpassen. + +Für Einsteiger ist die Lage eng. Sheth erklärte auf dem Panel, dass Neue meist mit Gegnerdesign und -umsetzung beginnen und nicht gleich das ganze Kampfsystem neu entwerfen. Die meisten Anzeigen verlangen mindestens ein veröffentlichtes Spiel; Larian etwa „3 years experience in a design position“. + +## Die Branche in Deutschland + +Laut Jahresreport 2026 des Verbands game arbeiten in Deutschland 12 235 Menschen bei Entwicklern und Publishern, drei Prozent weniger als im Vorjahr (Stichtag 31. März 2026). Es gibt 956 Unternehmen, davon 474 reine Entwicklungsstudios. Eine im Report zitierte Goldmedia-Studie zählt über drei Viertel der Unternehmen mit weniger als zehn Beschäftigten. Die größten Arbeitgeber sind Nintendo of Europe, Ubisoft, Stillfront, InnoGames und Crytek ([game – Jahresreport 2026, PDF](https://www.game.de/wp-content/uploads/2026/08/Jahresreport-der-deutschen-Games-Branche-2026-1-1.pdf), [Übersicht](https://www.game.de/publikationen/jahresreport-2026/)). + +Für Combat Design heißt das: Eigene Stellen mit diesem Titel sind in Deutschland selten. Bei der Recherche im Oktober 2026 war keine offen; frühere Ausschreibungen von Deck13 und Crytek sind beendet. Kampfnahe Arbeit steckt hier häufiger in Gameplay-, Technical- oder System-Design-Stellen, etwa bei InnoGames in Hamburg mit „combat balancing“ und „squad combat“ ([Games-Career](https://www.games-career.com/Joboffer/33798_Senior-Game-Designer_InnoGames-GmbH)). Viele Combat-Stellen gibt es im europäischen Ausland und remote. + +Wege in die Branche, die nicht über eine Stellenanzeige laufen: + +- **Eigene Spiele.** Ein kleines, veröffentlichtes Spiel zählt in Bewerbungen wie ein ausgeliefertes Projekt. Der Bund fördert Spieleentwicklung über die [Games-Förderung](https://www.bmftr.bund.de/DE/Technologie/Games/Games_node.html) mit einer Mindestprojektgröße von 300 000 Euro; für Solo-Projekte passen eher Landesförderungen und Wettbewerbe. +- **Veranstaltungen.** Die Entwicklerkonferenz der gamescom heißt seit Herbst 2025 [gamescom dev](https://dev.gamescom.global/) (früher devcom) und findet im August in Köln statt. +- **Jobbörsen.** [Games-Career](https://www.games-career.com/) ist auf die deutschsprachige Branche spezialisiert, international listen [Hitmarker](https://hitmarker.net/) und [Work With Indies](https://www.workwithindies.com/) viele Stellen. + +## Designtests und Gespräche + +Viele Studios lassen Design-Bewerber eine Aufgabe lösen. Peter Buchardt (Studio Gobo, früher Playdead) beschrieb 2018, dass Designtests in vielen Firmen zum Standard der Auswahl geworden sind ([GDC 2018: Design Tests and What to Expect from Them](https://gdcvault.com/play/1024935/Design-Tests-and-What-to)). CD Projekt Red und Remedy nennen eine praktische Aufgabe ausdrücklich in ihrem Bewerbungsablauf. + +Wie solche Aufgaben aussehen, zeigt Richard Carrillo (Ubisoft Toronto) in seinen Folien: Ein Panzer soll als Gegnerbegegnung für Far Cry entworfen werden, und jede Spielweise, vom Schleicher mit Bogen bis zum Sprengmeister, muss ihn schlagen können. Schwache Antworten umgehen die Begegnung oder bestehen nur aus Verweisen auf andere Spiele ([GDC 2017: Interviewing for Game Design](https://gdcvault.com/play/1024436/Interviewing-for-Game)). Ein Erfahrungsbericht zu einem Level-Design-Test bei Ubisoft Reflections nennt 48 Stunden Arbeitszeit in zehn Tagen, mit Layouts, Dokument und Präsentation als Abgabe ([Game Developer](https://gamedeveloper.com/business/my-first-experience-performing-a-level-design-test)). + +### Designtest: so gehst du vor + +1. **Frage, was bewertet wird.** Idee, Umsetzung im Editor, Dokument oder alles. Halte dich an die Zeitvorgabe. +2. **Schreib das Ziel in einem Satz.** Was soll der Spieler in diesem Kampf lernen oder erleben? +3. **Leg Zahlen fest.** Wind-up, aktive Phase, Erholung, Telegraph, Punish-Fenster, in Frames. Eine Tabelle zeigt, dass du über Timing nachgedacht hast. +4. **Zeig, wie es scheitern kann.** Was passiert, wenn der Spieler dauernd drückt? Wenn er nur ausweicht? Wenn er außerhalb des Bildes steht? +5. **Beleg eine Iteration.** Eine Änderung mit Hypothese, Messung und Entscheidung wiegt mehr als zehn Ideen ohne Test. +6. **Denk laut.** Carrillos Rat an Bewerber: „Think Out Loud“ und „Game Design Isn't About Your Ideas“. + +## Portfolio: was zählt + +Kate Killick (Mojiworks) rät Design-Absolventen: Fallstudien schlagen jede andere Form, Qualität vor Menge, und schreib „ich“, nicht „wir“, damit klar ist, was deine Arbeit war ([PocketGamer.biz](https://www.pocketgamer.biz/11-portfolio-tips-for-game-design-graduates/)). Für Combat Design heißt eine Fallstudie: + +- **Ein spielbarer Prototyp**, der eine Sache gut macht, etwa ein Gegner mit drei lesbaren Angriffen. +- **Ein kurzes Video mit Debug-Anzeige**, auf dem man Zustand, Frame und Hitboxen sieht. Das zeigt, dass du misst. +- **Eine Frame-Tabelle** aller Angriffe und des Ausweichens. +- **Ein Iterationsprotokoll** mit drei bis fünf Runden: Hypothese, Änderung, Messung, Entscheidung. Das Abschlussprojekt dieses Bereichs liefert genau das. +- **Eine Analyse eines veröffentlichten Kampfes**, Bild für Bild, mit eigener Begründung, warum er fair wirkt. + +Studios wissen, dass vieles unter Verschwiegenheitsvereinbarungen steht. People Can Fly schreibt bei einer Kamera-Stelle ausdrücklich, ein Portfolio sei deshalb nicht Pflicht. Ein eigener Prototyp ist umso wertvoller. + +## Lernplan in Wochen + +Der Plan geht von etwa vier Stunden pro Woche aus: kurze Einheiten unterwegs, eine längere am PC. + +| Woche | Unterwegs | Am PC | +| --- | --- | --- | +| 1 | [Spielgefühl](/combat/spielgefuehl) | [Godot-Start](/combat/godot-start) | +| 2 | [Eingabe und Puffer](/combat/eingabe) | Startszene messen | +| 3 | [Angriffsphasen](/combat/angriffsphasen) | [Testszene: Angriff](/combat/testszene-angriff) | +| 4 | [Hitboxen](/combat/hitboxen) | Testszene: Dummy, Log, Hitstop | +| 5 | [Ausweichen](/combat/ausweichen) | [Testszene: Ausweichen](/combat/testszene-ausweichen) | +| 6 | [Gegnermuster](/combat/gegnermuster) | Trainingsgegner mit Muster | +| 7 | [Große Gegner](/combat/grosse-gegner) | Großer Trainingsgegner | +| 8 | [Trefferfeedback](/combat/trefferfeedback) | [Tuning als Experiment](/combat/tuning) | +| 9 | [Schwer, unfair oder kaputt?](/combat/frust-diagnose) | Tuning-Runden mit Protokoll | +| 10 | [Vorgaben](/combat/vorgaben) | Abschlussprojekt beginnen | +| 11–14 | Clips analysieren, Karten wiederholen | Abschlussprojekt | + +## Ressourcen + +- Steve Swink: *Game Feel. A Game Designer's Guide to Virtual Sensation*, CRC Press 2009 ([Verlag](https://www.routledge.com/Game-Feel-A-Game-Designers-Guide-to-Virtual-Sensation/Swink/p/book/9780123743282)). +- Jesse Schell: *The Art of Game Design. A Book of Lenses*, 3. Auflage 2020 ([Verlag](https://www.routledge.com/The-Art-of-Game-Design-A-Book-of-Lenses-Third-Edition/Schell/p/book/9781138632059)). +- Tynan Sylvester: *Designing Games* ([Website des Autors](https://tynansylvester.com/book/)). +- Raph Koster: *A Theory of Fun for Game Design* ([Website des Buchs](https://www.theoryoffun.com/)). +- GDC Vault, freie Vorträge: [Evolving Combat in God of War](https://gdcvault.com/play/1026423/Evolving-Combat-in-God-of), [Embracing Push Forward Combat in DOOM](https://gdcvault.com/play/1024940/Embracing-Push-Forward-Combat-in), [Creating Conflict: Combat Design for AAA Action Games](https://gdcvault.com/play/1023860/Creating-Conflict-Combat-Design-for), [alle freien Inhalte](https://gdcvault.com/free). +- Kurze Clips: [Masahiro Sakurai on Creating Games](https://www.youtube.com/@sora_sakurai_en), [Juice it or lose it](https://www.youtube.com/watch?v=Fy0aCDmgnxg), [The art of screenshake](https://www.youtube.com/watch?v=AJdEqssNZ-U). diff --git a/content/combat/cheatsheet.md b/content/combat/cheatsheet.md new file mode 100644 index 0000000..9ac92dc --- /dev/null +++ b/content/combat/cheatsheet.md @@ -0,0 +1,126 @@ +## Frames und Millisekunden (60 pro Sekunde) + +| Frames | ms | Frames | ms | +| --- | --- | --- | --- | +| 1 | 16,7 | 13 | 216,7 | +| 2 | 33,3 | 15 | 250 | +| 3 | 50 | 20 | 333,3 | +| 4 | 66,7 | 24 | 400 | +| 6 | 100 | 30 | 500 | +| 8 | 133,3 | 45 | 750 | +| 10 | 166,7 | 60 | 1 000 | +| 12 | 200 | | | + +`ms = Frames × 1000 / 60` · `Frames = ms × 60 / 1000` · Pixel pro Frame = `px/s ÷ 60` + +## Phasen eines Angriffs zählen + +| Testszene | Frames | Street Fighter 6 | +| --- | --- | --- | +| Wind-up 8 | 1–8 | Startup 9 | +| aktiv 3 | 9–11 | Active 9-11 | +| Erholung 14 | 12–25 | Recovery 14 | +| frei | ab 26 | | + +Startup zählt den ersten aktiven Frame mit. Prüf bei jeder Tabelle, welche Zählung sie nutzt. + +## Richtwerte aus Quellen (Beispiele, keine Empfehlung) + +| Größe | Wert | Quelle | +| --- | --- | --- | +| einfache Reaktionszeit | 231 ms, korrigiert 213 ms | Woods et al. 2015 | +| Reaktion auf Ton | schneller als auf Licht | Jain et al. 2015 | +| Eingabelatenz, schnell | 3/60 s = 50 ms | Mick West 2008 | +| Eingabelatenz, 60-fps-Spiele | typisch 4/60 s | Mick West 2008 | +| Coyote Time Celeste | 0,1 s | Celeste-Quellcode | +| Hitstop Dreamscaper | etwa 7 Frames | GDC 2022 | +| Smash-Startup | etwa 15 Frames | Sakurai | +| Street Fighter 2 | etwa 7–8 Frames | Sakurai | +| SF6 Ryu Stand LP | 4 / 4-6 / 7, +4 / -1 | Capcom, Stand 10/2026 | + +## Regeln der Testszene + +| Regel | Wirkung | +| --- | --- | +| Zustand beginnt mit `frame = 1` | Dauer N belegt genau N Frames | +| Wechsel bei `frame > Dauer` | `>=` verkürzt um einen Frame | +| Puffer: `buffer_age <= input_buffer` | 0 = nur im selben Frame | +| Puffer altert nicht im Hitstop | Druck im Stillstand bleibt gültig | +| `cancel_from = N` | Anschluss ab Erholungsframe N, 0 = nie | +| Hitbox an in Frame X | `area_entered` in Frame X + 1 | +| Hitstop H | jeder betroffene Zustand + H Frames | +| i-Frames | Hurtbox `monitorable = false` | +| Hitstop nur bei gezähltem Treffer | `receive()` gibt `true` zurück | + +## Godot 4.7: Pixelart einrichten + +```ini title=project.godot +[display] +window/size/viewport_width=320 +window/size/viewport_height=180 +window/stretch/mode="viewport" +window/stretch/scale_mode="integer" + +[rendering] +textures/canvas_textures/default_texture_filter=0 +2d/snap/snap_2d_transforms_to_pixel=true +``` + +Neue Projekte starten in 4.7 mit `canvas_items` – `viewport` selbst setzen. + +## Godot 4.7: Timing und Treffer + +| Thema | Richtig | +| --- | --- | +| Gameplay-Takt | `_physics_process`, 60 pro Sekunde | +| Animation schaltet Hitbox | `callback_mode_process = Physics` | +| Animationsraster | Seconds/FPS auf FPS, 60 | +| Form im Signal umschalten | `set_deferred(&"disabled", …)` | +| Hurtbox verstecken | `monitorable`, nicht `monitoring` | +| Hitstop | eigener Zähler, nicht `Engine.time_scale` | +| Richtung vom Stick | `Input.get_vector()`, Länge ≤ 1 | +| Einzelbild | F9 Pause, F10 nächster Frame | +| Bild für Bild aufnehmen | Movie Maker, PNG-Ausgabe | + +`Engine.time_scale = 0`: `_physics_process` läuft weiter, `delta` ist 0. + +## Tuning-Runde + +```text +Runde: 3 +Godot: 4.7.2 +Hypothese: -> +Größe: alt -> neu +Erwartung: +Messung: +Eindruck: +Entscheidung: behalten / zurück / weiter testen +Offen: +``` + +Eine Größe pro Runde. Spieler- und Gegnerwerte nie zugleich. + +## Schwer, unfair oder kaputt? + +| Frage | Wenn nein | +| --- | --- | +| Gab es eine Ankündigung? | Lesbarkeit | +| War sie länger als die Reaktionszeit? | Lesbarkeit oder Absicht | +| War der Angreifer im Bild? | Lesbarkeit, Kamera | +| Steht dein Druck im Log? | Eingabe verloren: technisch | +| Passte die Hitbox zum Bild? | technisch | +| Bleibt das Ergebnis bei 30 Bildern/s gleich? | technisch | +| Hättest du mit Übung ausweichen können? | sonst unfair | + +## Vorgabe für einen Coding-Agenten + +| Teil | Inhalt | +| --- | --- | +| Ziel | Was der Spieler erleben soll, eigene Worte | +| Zustände | Liste und Übergänge | +| Parameter | Name, Einheit, Bereich, Platzhalter | +| Konflikte | was was abbricht, Puffer, Hitstop | +| Debug | Log-Zeilen mit Frame | +| Abnahme | am Log prüfbar | +| Offen | Fragen | +| Nicht entscheiden | Werte, Gefühl | diff --git a/content/combat/glossary.yaml b/content/combat/glossary.yaml new file mode 100644 index 0000000..48e3513 --- /dev/null +++ b/content/combat/glossary.yaml @@ -0,0 +1,95 @@ +terms: + - term: Frame + definition: "Ein Physik-Tick, in Godot standardmäßig 1/60 Sekunde, also etwa 16,7 ms. In diesem Bereich die Einheit für alle Zeiten im Kampf. Umrechnung: `ms = Frames × 1000 / 60`." + - term: Frame-Daten + definition: Tabelle mit den Zeiten eines Angriffs in Frames – Startup bzw. Wind-up, aktive Frames, Erholung, dazu oft Vorteil bei Treffer. Offizielle Beispiele veröffentlicht Capcom für Street Fighter 6. + - term: Wind-up + definition: Ausholphase eines Angriffs vor dem ersten aktiven Frame. Kündigt den Angriff an und gibt ihm Wucht; in der Testszene das Feld `windup` von `AttackData`. + - term: Startup + definition: In offiziellen Frame-Daten (etwa Street Fighter 6) der Frame, in dem die Hitbox zum ersten Mal erscheint. Zählt den ersten aktiven Frame mit; Wind-up 8 entspricht Startup 9. + - term: Aktive Frames + definition: Frames, in denen die Hitbox eines Angriffs treffen kann. In der Testszene `active`. + - term: Erholung + definition: Frames nach der aktiven Phase, in denen die Figur noch nicht wieder handeln kann (englisch Recovery). Der Preis eines Angriffs und das Fenster für einen Punish. + - term: Follow-through + definition: Ausschwingen nach dem Treffer, Teil der Erholung. Sakurai beschreibt es als die Phase, in der sich die Wucht eines Angriffs zeigt. + - term: Hitbox + definition: Fläche, mit der ein Angriff trifft. In Godot eine `Area2D` mit Form, die nur während der aktiven Frames eingeschaltet ist. + - term: Hurtbox + definition: Fläche, an der eine Figur getroffen werden kann. Muss nicht mit dem Bild übereinstimmen; Sakurai hält einen Größenunterschied für unproblematisch, einen Unterschied in der Position nicht. + - term: Disjointed Hitbox + definition: Hitbox, die nicht an einer Hurtbox der Figur sitzt, etwa die Spitze eines Schwerts oder ein Projektil. Wer sie trifft, trifft die Figur nicht. + - term: Kollisionsebene + definition: In Godot eine von 32 Ebenen (`collision_layer`), in der ein Objekt liegt. Ein Objekt erkennt ein anderes nur, wenn dessen Ebene in seiner Maske (`collision_mask`) steht. + - term: Telegraph + definition: Ankündigung eines gegnerischen Angriffs durch Pose, Blinken, Bodenmarkierung oder Ton. In der Testszene der Wind-up eines gegnerischen Angriffs mit gelber Bodenfläche. + - term: Tell + definition: Englisch für ein Zeichen, an dem man einen kommenden Angriff erkennt; oft gleichbedeutend mit Telegraph verwendet. + - term: Input Buffer + definition: Eingabepuffer. Merkt sich einen Tastendruck, der zu früh kam, und führt die Aktion im ersten möglichen Frame aus. In der Testszene zählt ein Druck, wenn er höchstens `input_buffer` Frames alt ist. + - term: Coyote Time + definition: Kurze Frist, in der eine Aktion noch erlaubt ist, obwohl ihre Bedingung gerade weggefallen ist, etwa ein Sprung kurz nach dem Verlassen der Kante. Im Quellcode von Celeste 0,1 Sekunden. + - term: Eingabelatenz + definition: Zeit vom Tastendruck bis zur sichtbaren Reaktion auf dem Bildschirm. Setzt sich aus Controller, Spiel, Rendern und Bildschirm zusammen. + - term: Reaktionszeit + definition: Zeit, die ein Mensch braucht, um auf ein Signal zu reagieren. Für einfache Reaktionen auf ein bekanntes Signal maßen Woods et al. 2015 im Mittel 213 ms nach Abzug der Geräteverzögerung, also knapp 13 Frames. + - term: Commitment + definition: Wie fest ein Spieler nach dem Drücken an seine Aktion gebunden ist. Lange Erholung und keine Abbruchmöglichkeit bedeuten viel Commitment. + - term: Cancel + definition: Abbruch einer laufenden Aktion durch eine andere, nur in festgelegten Frames erlaubt. In der Testszene `cancel_from` für Anschlussangriffe aus der Erholung. + - term: Anschlussangriff + definition: Angriff, der aus der Erholung des vorigen startet, bevor diese vorbei ist. Eine Folge solcher Angriffe heißt Kombo oder String. + - term: i-Frames + definition: Frames, in denen eine Figur nicht getroffen werden kann, etwa beim Ausweichen. Die Testszene schaltet dafür die Hurtbox mit `monitorable` ab. + - term: Dodge + definition: Ausweichmanöver, oft eine Rolle oder ein Dash. Besteht aus Bewegung, meist i-Frames und einer Erholung. + - term: Punish + definition: Treffer in die Erholung eines Gegners. Das Punish-Fenster sind die Frames, in denen du frei bist und der Gegner noch nicht. + - term: Frame Advantage + definition: Wie viele Frames eine Seite nach einer Aktion früher handeln kann als die andere. Street Fighter 6 nennt die Werte „on Hit“ und „on Block“. + - term: Hitstop + definition: Kurzes Einfrieren von Angreifer und Ziel im Moment des Treffers. Macht Treffer spürbar, verlängert aber alle betroffenen Zustände um seine Dauer. + - term: Hitstun + definition: Frames, in denen eine getroffene Figur nicht handeln kann. In der Testszene `hitstun`. + - term: Knockback + definition: Zurückstoßen beim Treffer. In der Testszene in Pixeln pro Sekunde, abklingend. + - term: Screenshake + definition: Kamerawackeln bei starken Ereignissen. In Godot meist über `Camera2D.offset`. Vlambeer musste Nuclear Throne eine Option zum Abschalten geben, weil manchen Spielern übel wurde. + - term: Trauma + definition: Wert zwischen 0 und 1, aus dem Squirrel Eiserloh die Stärke eines Screenshakes berechnet (etwa Trauma²). Treffer erhöhen ihn, er klingt mit der Zeit ab. + - term: Juice + definition: Rückmeldungen, die eine Aktion verstärken, ohne die Regeln zu ändern – Blitz, Partikel, Ton, Shake, Squash and Stretch. Begriff aus dem Vortrag „Juice it or lose it“. + - term: Game Feel + definition: Nach Steve Swink „the tactile sensation of manipulating a digital agent“, das Gefühl beim Steuern. Entsteht aus Eingabe, Reaktion und Rückmeldung. + - term: Hyper Armor + definition: Eigenschaft eines Gegners, bei Treffern seinen Angriff nicht abzubrechen. In der Testszene `interruptible = false`. + - term: Trefferzone + definition: Eine von mehreren Hurtboxen einer großen Figur, etwa Kopf oder Bein, oft mit eigenem Schadensfaktor. In der Testszene `zone` und `damage_multiplier`. + - term: Phase + definition: Abschnitt eines Bosskampfs mit eigenem Muster. Wechselt meist bei einem Lebensstand oder nach einer Zeit. + - term: Angriffstoken + definition: Erlaubnis zum Angreifen aus einem begrenzten Vorrat. Begrenzt, wie viele Gegner gleichzeitig angreifen; beschrieben in Santa Monica Studios Vortrag zu God of War. + - term: Kung-Fu-Kreis + definition: Muster, bei dem nur wenige Gegner gleichzeitig angreifen, während die anderen im Kreis warten, wie in einer Kampfszene im Film. + - term: Muster + definition: Feste Abfolge von Angriffen eines Gegners. In der Testszene die Liste `attacks`, die der Reihe nach abgearbeitet wird. + - term: Zustandsautomat + definition: Logik, bei der eine Figur immer in genau einem Zustand ist und nach Regeln in den nächsten wechselt. In der Testszene `MOVE`, `WINDUP`, `ACTIVE`, `RECOVERY` und weitere. + - term: Physik-Interpolation + definition: Godot zeichnet Zwischenstände zwischen zwei Physik-Frames, damit Bewegung auf Monitoren über 60 Hz flüssig aussieht. Seit Godot 4.3 auch in 2D; erhöht die Eingabeverzögerung etwas. + - term: Pixel-Snap + definition: Rundet beim Zeichnen jede Position auf ganze Pixel (`snap_2d_transforms_to_pixel`). Die Position im Spiel bleibt eine Kommazahl. + - term: Stretch Mode viewport + definition: Godot zeichnet das Bild in der kleinen Grundauflösung und vergrößert es als Ganzes. Die Einstellung für Pixelart ohne Subpixel-Bewegung. + - term: Deadzone + definition: Bereich um die Mitte eines Sticks, der als 0 zählt. In Godot je Aktion einstellbar, für neue Aktionen 0,2; `Input.get_vector()` rechnet sie heraus. + - term: Hypothese + definition: Beim Tuning die vorher aufgeschriebene Vermutung, was eine Änderung bewirkt und warum. Ohne sie lässt sich ein Test nicht auswerten. + - term: Iterationsprotokoll + definition: Aufzeichnung jeder Tuning-Runde mit Hypothese, geänderter Größe, Messung, Eindruck und Entscheidung. + - term: Playtest + definition: Test eines Spiels mit Spielern. Valve beschreibt Designs als Hypothesen und Playtests als Experimente; beobachtet wird, was Spieler tun, nicht was sie sagen. + - term: One-Page Design + definition: Designdokument, das auf genau eine Seite passt. Stone Librande stellte die Methode 2010 auf der GDC vor. + - term: Movie Maker + definition: Godot-Modus, der ein Spiel mit fester Bildrate als Video oder PNG-Folge aufnimmt. Mit PNG lässt sich jeder Frame einzeln ansehen. diff --git a/content/combat/interview.yaml b/content/combat/interview.yaml new file mode 100644 index 0000000..9cb3c28 --- /dev/null +++ b/content/combat/interview.yaml @@ -0,0 +1,307 @@ +cards: + - id: game-feel-definition + module: spielgefuehl + q: Was verstehst du unter Game Feel? + a: | + Das Gefühl beim Steuern einer Figur. Steve Swink nennt es „the tactile sensation of manipulating a digital agent“. Es entsteht aus drei Dingen, die sich messen lassen: wie schnell die Figur auf eine Eingabe reagiert, was sie in der Simulation tut und welche Rückmeldung Bild und Ton geben. Wenn jemand sagt, ein Spiel fühle sich „matschig“ an, suche ich zuerst in diesen drei Gliedern. + tags: [game-feel, grundlagen] + level: 1 + - id: frames-statt-gefuehl + module: spielgefuehl + q: Warum redest du über Kampf in Frames statt in Eindrücken? + a: | + Weil Eindrücke sich nicht übergeben lassen und Frames schon. „Der Schlag ist zu langsam“ kann zehn Ursachen haben. „Der Wind-up dauert 14 Frames, ich will testen, wie 10 sich anfühlen“ kann ein Programmierer umsetzen und ich am Log prüfen. Das Gefühl beurteile ich danach trotzdem selbst, aber jetzt weiß ich, was ich beurteile. + tags: [game-feel, frames] + level: 1 + - id: juice-grenzen + module: spielgefuehl + q: Kann gutes Trefferfeedback eine schwache Mechanik retten? + a: | + Nein, es verstärkt nur, was da ist. Blitz, Ton und Screenshake machen einen guten Treffer deutlicher. Ist der Angriff schlecht getimt oder die Hitbox falsch, verstärken sie eben das. Deshalb stimme ich zuerst Timing und Flächen ab und lege Feedback erst darüber, wie es auch „Juice it or lose it“ zeigt: an einer Mechanik, die schon funktioniert. + tags: [game-feel, feedback] + level: 2 + - id: reaktionszeit-frames + module: eingabe + q: Wie lange braucht ein Mensch, um auf ein Signal zu reagieren, und was heißt das in Frames? + a: | + Für einfache Reaktionen auf ein bekanntes Signal maßen Woods et al. 2015 im Mittel 231 ms, nach Abzug der Geräteverzögerung 213 ms. Das sind knapp 13 Frames bei 60 pro Sekunde. Im Spiel kommt Erkennen und Entscheiden dazu, und die Eingabelatenz des Systems. Eine Ankündigung, die deutlich kürzer ist, kann man nicht lesen und beantworten, nur auswendig lernen. + tags: [eingabe, reaktionszeit] + level: 1 + - id: input-buffer-erklaeren + module: eingabe + q: Was ist ein Input Buffer, und welches Problem löst er? + a: | + Er merkt sich einen Tastendruck, der kam, während die Figur noch nicht handeln konnte, und führt die Aktion im ersten freien Frame aus. Ohne Puffer geht ein Druck verloren, der drei Frames vor dem Ende der Erholung kam, und der Spieler hält das Spiel für träge. Wie groß der Puffer ist, ist eine Designentscheidung: Zu groß, und Aktionen kommen, die der Spieler nicht mehr wollte. + tags: [eingabe, puffer] + level: 1 + - id: latenzkette + module: eingabe + q: Ein Tester sagt, das Spiel reagiere träge. Wo suchst du? + a: | + Ich trenne Latenz und Design. Latenz entsteht in der Kette Controller, Spiel, Rendern, Bildschirm; Mick West nennt 50 ms als schnellsten realistischen Wert, 60-fps-Spiele liegen typisch bei vier Frames. Design ist das, was nach dem Druck passiert: ein langer Wind-up, keine sofortige Posenänderung, eine Erholung, die den nächsten Druck schluckt. Ich messe zuerst, ob der Druck im Log im erwarteten Frame ankommt, und schaue dann auf die Frame-Daten. + tags: [eingabe, latenz] + level: 2 + - id: coyote-time + module: eingabe + q: Was ist Coyote Time, und warum ist sie keine Schummelei? + a: | + Eine kurze Frist, in der eine Aktion noch klappt, obwohl ihre Bedingung gerade weggefallen ist, etwa ein Sprung kurz nach der Kante. Im Quellcode von Celeste sind das 0,1 Sekunden. Sie gleicht aus, dass Spieler Absicht und Bild minimal versetzt wahrnehmen. Gute Nachsicht merkt man nicht, man merkt nur ihr Fehlen. + tags: [eingabe, nachsicht] + level: 2 + - id: phasen-eines-angriffs + module: angriffsphasen + q: Aus welchen Phasen besteht ein Angriff, und wofür ist jede da? + a: | + Wind-up, aktive Phase, Erholung. Der Wind-up kündigt an und gibt Wucht, die aktive Phase ist das Zeitfenster der Hitbox, die Erholung ist der Preis: In ihr ist der Angreifer verwundbar. Sakurai zerlegt es für Animatoren in Idle, Lead-in, Attack und Follow-through und gibt ihnen vier Zahlen vor: Idle-Pose, Angriffspose, Hitbox-Frame, Gesamtlänge. + tags: [angriff, frame-daten] + level: 1 + - id: startup-zaehlung + module: angriffsphasen + q: In einer Tabelle steht „Startup 10“. Wann trifft der Angriff? + a: | + In den offiziellen Frame-Daten von Street Fighter 6 heißt Startup der Frame, in dem die Hitbox erscheint, er zählt also den ersten aktiven Frame mit. Der Angriff trifft ab Frame 10, davor liegen neun Frames Ausholen. Andere Tabellen zählen nur den Wind-up. Bevor ich Werte vergleiche, kläre ich, welche Zählung sie nutzen. + tags: [angriff, frame-daten] + level: 2 + - id: frame-advantage + module: angriffsphasen + q: Was ist Frame Advantage, und warum interessiert er auch in Actionspielen ohne Block? + a: | + Wie viele Frames eine Seite nach einer Aktion früher handeln kann als die andere. Street Fighter 6 nennt ihn für Treffer und Block. Ohne Block bleibt die Frage dieselbe: Wer ist nach einem Treffer oder einem verfehlten Angriff zuerst wieder frei? Ist der Gegner im Hitstun kürzer gebunden als ich in meiner Erholung, kann er mich danach bestrafen. + tags: [angriff, punish] + level: 2 + - id: anschlussangriffe + module: angriffsphasen + q: Wie baust du Anschlussangriffe, ohne dass Spieler sie ungewollt auslösen? + a: | + Über ein Abbruchfenster in der Erholung: Ab einem bestimmten Frame darf ein gepufferter Angriff starten. Sakurai legt Cancel-Frames in den Follow-through, und die Pose dort muss bereit aussehen. Ungewollte Auslöser vermeide ich mit einem Puffer, der nicht zu groß ist, und indem ich nur Drücke zähle, die nach dem Start des vorigen Angriffs kamen. + tags: [angriff, cancel] + level: 3 + - id: hitbox-vs-hurtbox + module: hitboxen + q: Was ist der Unterschied zwischen Hitbox und Hurtbox? + a: | + Die Hitbox ist die Fläche, mit der ein Angriff trifft, die Hurtbox die, an der eine Figur verwundbar ist. Beide müssen nicht genau dem Bild folgen. Sakurai macht Hitboxen bewusst größer als die Waffe; ein Größenunterschied ist in Ordnung, ein Unterschied in der Position nicht, weil der Spieler dann woanders getroffen wird, als er sieht. + tags: [hitbox, grundlagen] + level: 1 + - id: grosszuegige-hitboxen + module: hitboxen + q: Sollten Spieler- und Gegner-Hitboxen gleich genau sein? + a: | + Nicht unbedingt. Das Team von Dreamscaper gibt Spielerangriffen großzügige Hitboxen und Gegnerangriffen präzise: Der Spieler trifft, wenn es knapp aussieht, und wird nur getroffen, wenn es eindeutig aussieht. Beides wirkt fair, obwohl die Regeln ungleich sind. Wie weit ich das treibe, prüfe ich im Playtest. + tags: [hitbox, fairness] + level: 2 + - id: reichweite-draufsicht + module: hitboxen + q: Wie bestimmst du in der Draufsicht, ob ein Angriff einen laufenden Gegner erreicht? + a: | + Ich rechne in Pixeln pro Frame. Bei 90 Pixeln pro Sekunde bewegt sich eine Figur 1,5 Pixel pro Frame. Läuft der Gegner während meines Wind-ups weg, muss die Hitbox seine neue Position abdecken, wenn sie angeht. Dann prüfe ich es mit sichtbaren Kollisionsformen und Einzelbild, statt mich auf das Bild zu verlassen. + tags: [hitbox, reichweite] + level: 2 + - id: iframes-umsetzen + module: testszene-ausweichen + q: Wie setzt du i-Frames technisch um, und wo liegt eine typische Falle? + a: | + Die Hurtbox ist während der i-Frames aus, in Godot über `monitorable`. Die Falle: Wer Treffer nur ablehnt, statt die Hurtbox abzuschalten, verliert bei langen Angriffen den Treffer nach den i-Frames, denn das Signal meldet nur das Hineinkommen. Deshalb teste ich jedes Ausweichen auch mit „zu früh ausweichen und stehen bleiben“ gegen einen langen Angriff. + tags: [ausweichen, i-frames, godot] + level: 3 + - id: risiko-belohnung-ausweichen + module: ausweichen + q: Wie machst du ein Ausweichen zu einer Entscheidung statt zu einem Reflex? + a: | + Über Kosten und Belohnung. Kosten sind Erholung, ein verwundbarer Anfang oder eine Abklingzeit, die Belohnung ist ein Punish-Fenster oder ein Vorteil. Furi zeigt das deutlich: Ein gelungener Parry heilt, das leichtere Ausweichen nicht. Ist Ausweichen gratis, drückt man es bei jedem Geräusch. + tags: [ausweichen, risiko] + level: 2 + - id: punish-fenster + module: ausweichen + q: Was ist ein Punish-Fenster, und wie misst du es? + a: | + Die Frames, in denen ich frei bin und der Gegner noch in seiner Erholung steckt. Im Log sehe ich, wann meine Figur nach dem Ausweichen wieder `MOVE` erreicht und wann der Gegner seine Erholung verlässt. Ob ein Angriff in das Fenster passt, hängt an seinem Wind-up und an der Strecke, die das Ausweichen zurückgelegt hat. + tags: [ausweichen, punish] + level: 2 + - id: telegraph-laenge + module: gegnermuster + q: Ein Gegnerangriff gilt als unfair. Was prüfst du an seinem Telegraph? + a: | + Länge, Kanal und Ort. Ist die Ankündigung länger als die Reaktionszeit plus die Frames, bis das Ausweichen schützt? Ist sie zu sehen oder zu hören, auch wenn viel los ist? Zeigt sie, wo der Angriff trifft? Naughty Dog hat für The Last of Us Part II die Ankündigungen länger und deutlicher gemacht, als sie das Ausweichen einführten. + tags: [gegner, telegraph] + level: 2 + - id: angriffstokens + module: gegnermuster + q: Wie verhinderst du, dass zehn Gegner gleichzeitig angreifen? + a: | + Mit Angriffstokens aus einem begrenzten Vorrat. Santa Monica Studio hat das für God of War beschrieben: Ein Gegner braucht ein Token, um anzugreifen, größere Gegner belegen mehrere, und ein Gegner außerhalb des Bildes, der aggressiv wird, soll nicht ins Bild gedrängt werden, bevor der Spieler ihn gesehen hat. Die Größe des Vorrats ist ein Schwierigkeitsregler. + tags: [gegner, aggression] + level: 3 + - id: muster-statt-zufall + module: gegnermuster + q: Sollten Gegner zufällig angreifen? + a: | + Das hängt daran, was der Spieler lernen soll. Ich trenne zwei Dinge: die Reihenfolge vorhersagen und einen Angriff an seiner Ankündigung erkennen. Für das Erste braucht es ein Muster. Capcom hat im rundenbasierten Monster Hunter Stories 2 zufälliges Verhalten durch Muster ersetzt, die Spieler vorhersehen und kontern können. Für das Zweite reicht eine gleichbleibende, klare Ankündigung je Angriff; dann bleibt auch eine zufällige Reihenfolge lesbar. + tags: [gegner, muster] + level: 2 + - id: grosse-gegner-lesbarkeit + module: grosse-gegner + q: Was ist bei großen Gegnern in der Draufsicht schwieriger als bei kleinen? + a: | + Ihre Angriffe decken große Flächen ab, ihr Körper verdeckt Teile des Bildes, und manchmal ragen sie aus dem Bild. Deshalb brauchen sie Ankündigungen am Boden, die die Fläche zeigen, eine Kamera, die sie im Bild hält, und klare Stellen, an denen sie verwundbar sind. Itay Keren beschreibt dafür Kameras, die herauszoomen oder sich von wichtigen Objekten anziehen lassen. + tags: [boss, lesbarkeit, kamera] + level: 2 + - id: trefferzonen + module: grosse-gegner + q: Wozu gibt man einem großen Gegner mehrere Trefferzonen? + a: | + Damit Position eine Entscheidung wird. Eine Zone, die mehr Schaden nimmt oder nur in bestimmten Momenten erreichbar ist, lenkt den Spieler an eine Stelle und gibt dem Kampf Struktur. In Shadow of the Colossus hängt das Design jedes Kolosses an der Art, wie man ihn besiegt. Technisch sind es mehrere Hurtboxen mit eigenem Namen und Schadensfaktor. + tags: [boss, hurtbox] + level: 2 + - id: boss-unterbrechbar + module: grosse-gegner + q: Sollte ein großer Boss durch Treffer unterbrochen werden? + a: | + Das ist eine Entscheidung mit Folgen. Ist er unterbrechbar, kann der Spieler ihn mit Dauerangriffen festhalten. Ist er es nie, wirken Treffer leicht wirkungslos. Dazwischen liegt, deutlich zu zeigen, wann er verwundbar ist, etwa in der Erholung nach einem großen Angriff oder in einer eigenen Phase. Welche Regel gilt, entscheidest du und prüfst es im Playtest. + tags: [boss, hyper-armor] + level: 3 + - id: hitstop-zweck + module: trefferfeedback + q: Was bewirkt Hitstop, und was kostet er? + a: | + Er friert Angreifer und Ziel im Moment des Treffers kurz ein, damit der Treffer spürbar wird und die Trefferpose lesbar ist. Sakurai lässt das Opfer dabei stärker zittern als den Angreifer. Der Preis: Jeder betroffene Zustand dauert um den Hitstop länger, der Kampf wird langsamer, und in Smash ist er begrenzt, weil ein Dritter das Einfrieren ausnutzen könnte. + tags: [feedback, hitstop] + level: 1 + - id: hitstop-time-scale + module: trefferfeedback + q: Warum nimmst du für den Hitstop nicht `Engine.time_scale = 0`? + a: | + Weil das in Godot nur `delta` auf null setzt. `_physics_process` läuft weiter, und ein Zustandsautomat, der Frames zählt, zählt weiter. Außerdem steht dann alles, auch Anzeigen. Ich halte deshalb gezielt Angreifer und Ziel mit einem eigenen Zähler an. + tags: [feedback, hitstop, godot] + level: 3 + - id: screenshake-regeln + module: trefferfeedback + q: Worauf achtest du bei Screenshake? + a: | + Stärke nach Bedeutung, kurz und abklingend, und eine Option zum Abschalten. Squirrel Eiserloh berechnet die Stärke aus einem abklingenden Trauma-Wert, etwa Trauma², mit Rauschen statt Zufall. Das Team von Dreamscaper lässt die Kamera beim eigenen Getroffenwerden viel weniger wackeln als beim Treffen. Vlambeer musste Nuclear Throne eine Option zum Abschalten geben, weil manchen Spielern übel wurde. + tags: [feedback, kamera] + level: 2 + - id: ton-beim-treffer + module: trefferfeedback + q: Was macht einen Trefferton gut? + a: | + Er sitzt im Moment des Treffers und endet bei starken Treffern während des Hitstops; Sakurai gibt starken Angriffen einen kurzen Hall mit der Spitze am Anfang. Das Team von Hyper Light Drifter baut Waffensounds aus vielen Schichten und holt die Wucht aus dem Bass. Wiederholte leichte Treffer variiere ich leicht, damit sie nicht ermüden. + tags: [feedback, sound] + level: 2 + - id: unfair-oder-schwer + module: frust-diagnose + q: Wie unterscheidest du einen schweren von einem unfairen Kampf? + a: | + Schwer heißt: Mit Übung kann ich jedem Angriff ausweichen, und wenn ich getroffen werde, weiß ich warum. Unfair heißt: Ich hatte keine Chance, weil die Ankündigung fehlte, zu kurz war oder außerhalb des Bildes lag. Miyazaki sagt für FromSoftware, sie glauben an schwere Spiele, aber nicht an unfaire. Ich frage nach jedem Treffer: Hätte ich es sehen und rechtzeitig reagieren können? + tags: [diagnose, fairness] + level: 1 + - id: kaputt-erkennen + module: frust-diagnose + q: Woran erkennst du, dass ein unfairer Treffer ein technischer Fehler ist? + a: | + Am Log und an der Reproduzierbarkeit. Steht mein Tastendruck im Log und die Aktion kam trotzdem nicht, ging die Eingabe verloren. Trifft die Hitbox an einer Stelle, an der das Bild nichts zeigt, stimmt die Fläche nicht. Ändert sich das Ergebnis bei niedriger Bildrate, hängt Timing am falschen Takt. Solche Fehler behebe ich, bevor ich an Werten drehe. + tags: [diagnose, technik] + level: 2 + - id: treffer-nachvollziehen + module: frust-diagnose + q: Wie findest du heraus, warum ein Spieler getroffen wurde? + a: | + Ich schaue mir genau diesen Moment Frame für Frame an: Log mit Frame-Nummern für Druck, Zustandswechsel und Treffer, Einzelbild in der Engine, sichtbare Kollisionsformen, bei Bedarf eine Aufnahme mit fester Bildrate. Dann ordne ich ihn einer Ursache zu: zu spät reagiert, nicht lesbar oder technischer Fehler. Und ich beobachte andere Spieler, denn Valve rät, darauf zu achten, was Spieler tun, nicht was sie sagen. + tags: [diagnose, debug] + level: 2 + - id: vorgabe-aufbau + module: vorgaben + q: Was gehört in eine Vorgabe, nach der ein Programmierer oder Coding-Agent eine Kampfmechanik baut? + a: | + Das Ziel in eigenen Worten, Zustände und Übergänge, Parameter mit Einheiten, Regeln für Konflikte wie Abbruch, Puffer und Hitstop, Debug-Anforderungen und Abnahmekriterien, die man am Log prüfen kann. Dazu ausdrücklich, was nicht entschieden werden soll: Werte bleiben einstellbare Parameter, das Spielgefühl beurteile ich selbst. + tags: [vorgaben, dokumentation] + level: 2 + - id: vorgabe-mehrdeutig + module: vorgaben + q: Was passiert, wenn in einer Vorgabe „kurzer Wind-up“ steht? + a: | + Wer baut, setzt irgendeine Zahl ein, und niemand merkt, dass das eine Designentscheidung war. Deshalb stehen in Vorgaben Frames bei 60 pro Sekunde und Pixel pro Sekunde, nie „kurz“ oder „schnell“. Ist eine Zahl noch offen, schreibe ich „Parameter, Platzhalter 8, wird getestet“. + tags: [vorgaben, frames] + level: 1 + - id: one-page-design + module: vorgaben + q: Warum soll ein Designdokument kurz sein? + a: | + Weil lange Dokumente nicht gelesen werden und veralten. Stone Librande hat auf der GDC 2010 One-Page Designs vorgestellt: genau eine Seite, oft als Diagramm. Für eine Kampfmechanik reicht meist eine Seite mit Zuständen, Frame-Tabelle, Konfliktregeln und Abnahme. + tags: [vorgaben, dokumentation] + level: 2 + - id: godot-physik-takt + module: godot-start + q: Wie stellst du in Godot sicher, dass ein Angriff unabhängig von der Bildrate gleich lange trifft? + a: | + Gameplay-Timing läuft in `_physics_process`, fest 60 pro Sekunde. Animationen, die Hitboxen schalten, laufen mit `callback_mode_process = Physics`; im Standardmodus Idle habe ich bei 30 Bildern pro Sekunde vier statt drei aktive Frames gemessen. Danach prüfe ich es bei künstlich gesenkter Bildrate. + tags: [godot, timing] + level: 2 + - id: pixel-perfect + module: godot-start + q: Wie richtest du in Godot Pixelart ohne sichtbare Subpixel-Bewegung ein? + a: | + Kleine Grundauflösung, Stretch Mode `viewport`, Scale Mode `integer`, Texturfilter `Nearest` und Pixel-Snap beim Zeichnen. Die Position der Figur bleibt eine Kommazahl, damit analoge Bewegung genau bleibt, gezeichnet wird in ganzen Pixeln. In Godot 4.7 starten neue Projekte mit `canvas_items`, das muss man also ausdrücklich umstellen. + tags: [godot, pixelart] + level: 2 + - id: zustandsautomat + module: testszene-angriff + q: Wie baust du einen Angriff mit Frame-Daten in Code? + a: | + Als Zustandsautomat, der Physik-Frames zählt: `WINDUP`, `ACTIVE`, `RECOVERY`, jeder mit einer Dauer aus einer Datenressource. In jedem Frame zuerst die Übergänge, dann das Verhalten. Die Hitbox ist genau im Zustand `ACTIVE` an. So stehen die Zahlen an einer Stelle, und ein Log mit Frame-Nummern zeigt, ob der Code tut, was die Tabelle sagt. + tags: [godot, zustandsautomat] + level: 2 + - id: area-signal-latenz + module: testszene-angriff + q: Du schaltest in Godot eine Hitbox ein. Wann kommt der Treffer an? + a: | + Einen Frame später. Der Physik-Schritt läuft nach dem Code des Frames, das Signal `area_entered` kommt am Anfang des nächsten. Das gehört in jede Frame-Rechnung. Umschalten im Signal selbst geht nur mit `set_deferred()`, sonst lehnt Godot die Änderung ab, und die Physik rechnet mit der alten Form weiter. + tags: [godot, treffer] + level: 3 + - id: werte-als-resource + module: testszene-angriff + q: Warum legst du Frame-Daten als Resource an und nicht als Konstanten im Code? + a: | + Weil ich sie im Inspektor ändere, ohne Code anzufassen, und Varianten als Dateien speichern kann. Für einen Vergleichstest ziehe ich `angriff_a.tres` oder `angriff_b.tres` in das Feld, und genau ein Wert unterscheidet sie. Das macht Tuning-Runden nachvollziehbar. + tags: [godot, tuning] + level: 2 + - id: drei-ausweichfaelle + module: testszene-ausweichen + q: Welche Fälle testest du an einem neuen Ausweichen? + a: | + Drei: einen Frame zu spät, gerade noch rechtzeitig und zu früh gegen einen Angriff, der länger aktiv ist als die i-Frames. Der erste zeigt, ob der Anfang verwundbar ist, der zweite die genaue Grenze, der dritte, ob die Figur nach den i-Frames wieder getroffen wird. Dazu prüfe ich, dass ein Ausweichen keinen Hitstop auslöst. + tags: [ausweichen, test] + level: 3 + - id: hypothese-zuerst + module: tuning + q: Wie stimmst du einen Kampfwert ab? + a: | + Wie ein Experiment: Vorher schreibe ich auf, was ich ändere, warum und was ich erwarte. Ich ändere genau eine Größe, vergleiche alte und neue Fassung, messe am Log und spiele. Dann entscheide ich und halte offene Fragen fest. Valve beschreibt Designs als Hypothesen und Playtests als Experimente; ohne Hypothese weiß ich hinterher nicht, was ich gelernt habe. + tags: [tuning, methode] + level: 2 + - id: stumpf-gegen-bewusst + module: tuning + q: Warum testest du Dauerdrücken und bewusstes Reagieren getrennt? + a: | + Weil sie unterschiedliche Fragen beantworten. Gewinnt man durch Dauerdrücken, belohnt der Kampf kein Lesen der Ankündigungen. Klappt bewusstes Reagieren nicht, sind die Fenster zu klein oder die Ankündigungen unklar. Erst der Vergleich zeigt, welche Spielweise mein Entwurf wirklich belohnt. + tags: [tuning, playtest] + level: 2 + - id: eigene-tests-voreingenommen + module: tuning + q: Reicht es, wenn du deinen Kampf selbst testest? + a: | + Nein. Ich kenne jedes Muster und jede Absicht, also sehe ich Ankündigungen, die andere übersehen. Ich lasse andere spielen, ohne zu erklären, und beobachte, was sie tun. Was sie sagen, notiere ich auch, aber das Verhalten zählt mehr, und eine Person ist eine Beobachtung, keine Statistik. + tags: [tuning, playtest] + level: 1 + - id: designtest-vorgehen + q: Du bekommst einen Designtest für eine Combat-Stelle. Wie gehst du vor? + a: | + Ich kläre, was bewertet wird und wie viel Zeit vorgesehen ist. Dann schreibe ich das Ziel in einem Satz, lege Zahlen in Frames fest, zeige, wie der Entwurf scheitern kann, und belege mindestens eine Iteration mit Hypothese und Messung. Beim Präsentieren denke ich laut, denn es geht um mein Vorgehen, nicht um die eine Idee. + tags: [karriere, designtest] + level: 2 + - id: zusammenarbeit-animation + q: Ein Animator will einen längeren Wind-up, weil die Pose sonst nicht wirkt. Was tust du? + a: | + Ich nehme beides ernst: Lesbarkeit und Reaktion. Zuerst schauen wir gemeinsam auf die Frame-Daten und das Spiel, nicht auf die Animation allein. Oft hilft eine stärkere Pose im ersten Frame statt mehr Frames, wie Sakurai für Lead-ins zeigt. Wenn wir uneinig bleiben, bauen wir beide Fassungen und testen sie. + tags: [karriere, zusammenarbeit] + level: 2 diff --git a/content/combat/modules/angriffsphasen.yaml b/content/combat/modules/angriffsphasen.yaml new file mode 100644 index 0000000..1632379 --- /dev/null +++ b/content/combat/modules/angriffsphasen.yaml @@ -0,0 +1,554 @@ +id: angriffsphasen +title: "Anatomie eines Angriffs: Wind-up, aktiv, Erholung" +level: 2 +minutes: 100 +summary: Wie ein Angriff in Phasen zerfällt, wie du offizielle Frame-Daten liest und in die Zählung der Testszene umrechnest, und was Wind-up, aktive Phase, Erholung und Abbruchfenster für Rückmeldung, Lesbarkeit, Wucht und Risiko leisten. +goals: + - Du benennst die vier Phasen eines Angriffs nach Sakurai und ordnest ihnen Wind-up, aktive Phase und Erholung zu. + - Du liest offizielle Frame-Daten und rechnest Startup und Active in Wind-up, aktive Frames und Gesamtdauer um. + - Du bestimmst aus einem Log oder einer Tabelle, wer nach einem Treffer zuerst wieder frei ist (Frame Advantage). + - Du erklärst, warum der Wind-up Rückmeldung, Lesbarkeit und Wucht liefert und die Erholung den Preis eines Angriffs festlegt. + - Du beschreibst einen eigenen Angriff mit Hitbox-Frame und Gesamtframes, ohne Werte anderer Spiele als Vorgabe zu übernehmen. +resources: + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 + - title: Masahiro Sakurai · Follow-Throughs Make the Impact + url: https://www.youtube.com/watch?v=cIB0BUe6Ihk + - title: Street Fighter 6 · Ryu Frame Data (Capcom) + url: https://www.streetfighter.com/6/character/ryu/frame + - title: Infil · The Fighting Game Glossary + url: https://glossary.infil.net/ + - title: Mariel Cartwright · Fluid and Powerful Animation within Frame Restrictions (GDC 2014) + url: https://www.youtube.com/watch?v=Mw0h9WmBlsw +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + In der Testszene drückst du in Frame 12 auf Angriff. Die Hitbox geht in Frame 20 an, der Dummy meldet den Treffer in Frame 21, und frei ist die Figur erst wieder in Frame 41. Von diesen 29 Frames, knapp einer halben Sekunde, ist die Hitbox 3 Frames lang wirksam, 4 weitere friert der Hitstop ein. Den Rest der Zeit holt die Figur aus oder erholt sich. Das ist kein Sonderfall: Laut dem Fighting Game Glossary von Infil haben die meisten Angriffe nur etwa 1 bis 4 aktive Frames, obwohl der ganze Angriff 30 Frames dauern kann ([Active](https://glossary.infil.net/?t=Active)). + + Wer einen Angriff beurteilen will, muss ihn deshalb zerlegen: Wann trifft er, wie lange, und was kostet er danach? Diese Lektion trennt die Phasen, liest offizielle Frame-Daten und zeigt, welche Aufgabe jede Phase für den Spieler hat. Welche Werte deine Angriffe bekommen, entscheidest du später selbst. + + ## Vier Phasen nach Sakurai: Idle, Lead-in, Attack, Follow-through + + Masahiro Sakurai (Kirby, Super Smash Bros.) teilt Angriffsanimationen in vier Phasen ([Breaking Down Attack Animations](https://www.youtube.com/watch?v=LewXWM7HDd8)): + + | Phase | Begriff im Kurs | Was passiert | + | --- | --- | --- | + | Idle | frei | Grundpose | + | Lead-in | Wind-up | Ausholen | + | Attack | aktive Phase | Hitbox an | + | Follow-through | Erholung | Figur gebunden | + + - **Idle** ist die Pose, aus der fast jede Bewegung entsteht. Sakurai baut sie zuerst und ändert sie danach nicht mehr. + - **Lead-in** ist der **Wind-up** (Startup), die Ausholphase vor dem Treffer. In Sakurais Spielen springt die Figur schon im ersten Frame weit in die Ausholpose, statt weich aus Idle hineinzugleiten. Der Spieler sieht so sofort, dass seine Eingabe angekommen ist, und der Gegner bekommt das Signal auszuweichen (ab etwa 0:43). + - **Attack** ist die Pose, in der die **Hitbox** erscheint, die Fläche, mit der ein Angriff trifft. Ein Treffer löst meist einen **Hitstop** aus, ein kurzes Einfrieren von Angreifer und Ziel. Die Pose steht dann still im Bild und muss sitzen. + - **Follow-through** ist die **Erholung** (Recovery): Die Hitbox ist aus, der Spieler kann noch nicht handeln. Sakurai füllt diese Zeit mit Bewegung, statt die Figur halb in Idle hängen zu lassen. + + Ab etwa 2:39 nennt Sakurai die vier Angaben, die seine Animatoren für jeden Angriff bekommen: Idle-Pose, Angriffspose, den Frame, in dem die Hitbox erscheint, und die Gesamtframes, also die Zeit bis zum Abbruch. Zwei Zahlen legen damit das Timing fest. Für den Platzhalterangriff der Testszene: + + ```text + Wind-up 8, aktiv 3, Erholung 14 + Frame 1–8 Wind-up Lead-in + Frame 9–11 aktiv Attack + Frame 12–25 Erholung Follow-through + Frame 26 frei Idle + ``` + + Hitbox-Frame 9, Gesamtframes 25. Dasselbe als Modell, das zu jedem Frame die Phase nennt. Ändere die Platzhalter in der ersten Zeile und lass den Block erneut laufen: + + ```python run + windup, aktiv, erholung = 8, 3, 14 + + def phase(f): + if f <= windup: + return "Wind-up" + if f <= windup + aktiv: + return "aktiv" + if f <= windup + aktiv + erholung: + return "Erholung" + return "frei" + + for f in (1, 8, 9, 11, 12, 25, 26): + print(f, phase(f)) + ``` + + ## Frame-Daten lesen und Phasen vergleichen + + Capcom veröffentlicht für Street Fighter 6 offizielle Frame-Daten. Drei Standardangriffe von Ryu, Stand Oktober 2026; die Werte ändern sich mit Patches ([Ryu Frame Data](https://www.streetfighter.com/6/character/ryu/frame)): + + | Angriff | Startup | Active | Recovery | + | --- | --- | --- | --- | + | St. LP | 4 | 4-6 | 7 | + | St. MP | 6 | 6-9 | 11 | + | St. HP | 10 | 10-14 | 18 | + + St. steht für Standing, LP, MP und HP für leichten, mittleren und schweren Schlag. Die Legende der Tabelle erklärt die Zählung: Steht dort 10-12, ist die Hitbox in den Frames 10 bis 12 aktiv. Frame 10 ist dann der erste aktive Frame. **Startup zählt den ersten aktiven Frame mit.** + + Die Testszene zählt anders: Wind-up 8 sind die 8 Frames vor der Hitbox, die Hitbox kommt in Frame 9. Wind-up 8 entspricht Startup 9. Infil warnt, dass Communitys hier unterschiedlich zählen, und rät, für jedes Spiel nachzufragen ([Startup](https://glossary.infil.net/?t=Startup)). Recovery zählt in beiden Fällen die Frames nach der aktiven Phase, bis die Figur wieder handeln kann ([Recovery](https://glossary.infil.net/?t=Recovery)). + + > [!MERKE] + > Wind-up = Startup − 1. Aktive Frames = letzter − erster aktiver Frame + 1. Gesamtdauer = Wind-up + aktiv + Erholung. Frei ist die Figur im Frame danach. + + ```python run + # Street Fighter 6, Ryu, Stand Oktober 2026 + # (Startup, letzter aktiver Frame, Recovery) + angriffe = { + "St. LP": (4, 6, 7), + "St. MP": (6, 9, 11), + "St. HP": (10, 14, 18), + } + for name, werte in angriffe.items(): + startup, bis, rec = werte + windup = startup - 1 + aktiv = bis - startup + 1 + gesamt = windup + aktiv + rec + print(name, windup, aktiv, rec, gesamt) + ``` + + Der schwere Schlag belegt 32 Frames, rund 533 ms (ms = Frames × 1000 / 60), und trifft in 5 davon. Diese Zahlen gehören zu Street Fighter 6, mit seiner Eingabeverzögerung, seinen Reichweiten und seinem Tempo. Sie sind Messpunkte zum Vergleichen, keine Vorlage für ein Top-down-Spiel. + + ### Kurz bis zum Treffer, lang danach + + Sakurai sagt in [Follow-Throughs Make the Impact](https://www.youtube.com/watch?v=cIB0BUe6Ihk): Standardangriffe in Smash enden in weniger als etwa einer Sekunde (ab 0:51). Die Hitbox erscheint nach etwa einem Drittel bis einem Viertel der Gesamtzeit, manchmal schon nach einem Fünftel; der Rest ist Follow-through (ab 1:03). Seine Gesamtzeit reicht bis zum ersten Abbruch-Frame. Weil die Erholung so lang ist, sieht der Spieler sie am längsten. Sakurai will, dass sie zeigt, was gerade passiert ist. Rechne den Anteil für die Testszene und zwei SF6-Angriffe nach, als ersten aktiven Frame geteilt durch die Gesamtdauer: + + ```python run + def anteil(windup, aktiv, erholung): + gesamt = windup + aktiv + erholung + return round((windup + 1) / gesamt, 2) + + print(anteil(8, 3, 14)) # Testszene + print(anteil(9, 5, 18)) # SF6 St. HP + print(anteil(3, 3, 7)) # SF6 St. LP + ``` + + Alle drei liegen nahe einem Drittel. Das ist eine Beobachtung an drei Angriffen, keine Regel. In seiner Kolumne zum Frame-Zählen nennt Sakurai eine zweite Größenordnung: Bei einem Smash-Angriff erscheint die erste Hitbox nach etwa 15 Frames, bei Street Fighter 2 nach etwa der Hälfte ([Learn to Count Frames!](https://sourcegaming.info/2018/05/18/learn-to-count-frames-sakurais-famitsu-column-vol-552/), Fan-Übersetzung). + + ### Pixelart hat wenige Bilder + + Physik-Frames legen fest, wann etwas passiert. Animationsframes, also Zeichnungen, legen fest, was man dabei sieht. In Dead Cells sind die Figuren 50 Pixel hoch, die Animationen laufen mit 30 Bildern pro Sekunde. Bei 60 Physik-Frames steht jede Zeichnung also mindestens zwei Frames. Ist eine Waffe zu stark, verlangsamt das Team ihre Angriffsanimation ([Art Design Deep Dive](https://www.gamedeveloper.com/production/art-design-deep-dive-using-a-3d-pipeline-for-2d-animation-in-i-dead-cells-i-)). Mariel Cartwright zeigt für das Sprite-Spiel Skullgirls einen schwachen Angriff mit sechs Zeichnungen, eine davon Ausholen: „you don't really see it … but you feel it“ (ab 2:47). Gleich lange Haltezeiten verschwenden Frames; ihr Team hielt die Schlüsselposen länger und die Wischbilder (Smears) kürzer (ab 11:59). Ein Schlag mit sieben Zeichnungen wurde mit einer weniger vor dem Treffer „a lot snappier“ (ab 18:20, [GDC 2014](https://www.youtube.com/watch?v=Mw0h9WmBlsw)). + + ## Wind-up: Rückmeldung, Lesbarkeit, Wucht + + Der Wind-up hat drei Aufgaben, die gegeneinander arbeiten können. + + **Rückmeldung.** Sakurai lässt die Figur in dem Frame reagieren, in dem das Spiel den Druck sieht. Er nennt das in [Making Lead-ins Instant and Impactful](https://www.youtube.com/watch?v=E8DKndKkHw8) das Ideal: Die Pose ändert sich ab dem Druck stark, auch wenn der Übergang aus Idle dadurch springt (ab 0:42). Ian Cofino beschreibt dasselbe für das Top-down-Spiel Dreamscaper: Der Angriff springt sofort in die Ausholpose, die Startup-Frames laufen trotzdem ab ([GDC 2022, ab 22:10](https://www.youtube.com/watch?v=3Omb5exWpd4&t=1330s)). Sofort reagieren heißt nicht sofort treffen. + + **Wucht.** Es gibt Angriffe fast ohne Wind-up. Bei Sakurai kommt der Jab von Zero Suit Samus einen Frame nach dem Druck (ab 1:19). Ohne richtiges Ausholen fehlt einem Treffer aber die Wucht, und online kann der Wind-up Verzögerung durch das Netz überdecken (ab 1:45). Schneller ist deshalb nicht automatisch besser. + + **Lesbarkeit.** Beim Gegner ist der Wind-up deine Reaktionszeit. Cartwright trennt das im selben Vortrag: Die Spielfigur muss auf Tastendrücke fast sofort reagieren, Gegner brauchen lange Wind-ups, damit der Spieler ausweichen, blocken oder weglaufen kann. Laut Infil treffen Angriffe mit 20 oder mehr Frames Startup selbst sehr gute Spieler oft ([Reaction](https://glossary.infil.net/?t=Reaction)). Ein lesbarer Wind-up hat eine eigene Pose mit klarer Silhouette und, wenn das Spiel es vorsieht, einen **Telegraph**: eine Ankündigung durch Blinken, Bodenmarkierung oder Sound. Bei wenigen Zeichnungen trägt jede Pose mehr: Ist sie nicht auf den ersten Blick lesbar, fehlt die Ankündigung. Mehr in [Gegnermuster und Telegraphing](/combat/gegnermuster). + + ## Erholung und Frame Advantage: wer zuerst wieder frei ist + + Die Erholung bestimmt, was ein Angriff kostet. Sakurai begründet sie mit dem Taktieren zwischen den Spielern: Hätten starke Angriffe keine nennenswerte Erholung, würde das Spiel grob und verlöre seine Feinheiten (Follow-Throughs, ab 0:39). Dreamscaper lässt das Ausweichen fast jede Aktion abbrechen, gibt Angriffen aber Startup und Erholung, damit Spieler bewusst angreifen (ab 22:57). Auch die SF6-Tabelle zeigt die Erholung als Preis: Verfehlt Ryus St. MP, kommen laut Capcom 2 Frames Erholung dazu. + + Einen Treffer in die Erholung des Gegners nennt man **Punish** (Bestrafen) ([Punish](https://glossary.infil.net/?t=Punish)). Ein Log der Testszene mit Ausweichen zeigt das Fenster: + + ```text + 34 Dummy ACTIVE -> RECOVERY + 46 Spieler DODGE -> DODGE_RECOVERY + 54 Spieler DODGE_RECOVERY -> MOVE + 64 Dummy RECOVERY -> WAIT + ``` + + Die Figur ist ab Frame 54 frei, der Dummy erholt sich bis Frame 63: 10 Frames Punish-Fenster. Jeder Frame mehr Erholung beim Dummy macht das Fenster einen Frame länger (siehe [Ausweichen, Risiko und Belohnung](/combat/ausweichen)). + + **Frame Advantage** beschreibt, wer nach einem Treffer oder einem geblockten Angriff zuerst wieder handeln kann ([Frame Advantage](https://glossary.infil.net/?t=Frame+Advantage)). SF6 nennt den Wert in zwei Spalten: + + | Angriff | on Hit | on Block | + | --- | --- | --- | + | St. LP | +4 | -1 | + | St. MP | +7 | -1 | + | St. HP | +4 | -2 | + + +4 on Hit heißt: Ryu kann 4 Frames vor dem getroffenen Gegner wieder handeln. −2 on Block heißt: Der Gegner ist 2 Frames früher frei als Ryu. Aus einem Log liest du dasselbe ab. Der Lauf mit Treffer aus der Testszene: + + ```text + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body, … + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + ``` + + ```python run + spieler_frei = 41 # RECOVERY -> MOVE + dummy_frei = 36 # HITSTUN -> WAIT + + vorteil = dummy_frei - spieler_frei + print(f"Spieler {vorteil:+d} on Hit") + ``` + + Mit diesen Platzhaltern ist der Angreifer nach einem Treffer 5 Frames im Nachteil. Drei Größen verschieben das Ergebnis: **Hitstun**, die Frames, in denen das Ziel nach dem Treffer nicht handeln kann, die Erholung des Angreifers und die Zahl der aktiven Frames, die nach dem Treffer noch laufen. Hier sind es 2 aktive plus 14 Erholung gegen 11 Frames Hitstun nach dem Hitstop: (12 − 1) − (2 + 14) = −5. Der Hitstop verlängert beide Seiten gleich, deshalb wechselt die Figur erst in Frame 27 statt 23 in die Erholung. + + > [!INTERVIEW] + > „Was heißt −2 on Block in einer Frame-Tabelle, und wie prüfst du so einen Wert in deinem Spiel?“ Gute Antwort: Nach einem geblockten Angriff ist der Angreifer zwei Frames länger gebunden als der Verteidiger, der also zuerst handeln kann. Ein Spiel ohne Block stellt dieselbe Frage nach einem Treffer oder Fehlschlag: Wer ist zuerst frei? Im Log liest du es ab, als Ende der Erholung des Angreifers gegen das Ende des Hitstuns beim Ziel. Ob ein Angriff sicher sein soll, entscheidet das Design; die Zahl zeigt nur, ob er es ist. + + ## Anschlussangriffe und Abbruchfenster + + Ein **Cancel** (Abbruchfenster) erlaubt, die Erholung abzubrechen und sofort die nächste Aktion zu starten ([Cancel](https://glossary.infil.net/?t=Cancel)). Sakurai setzt dafür Cancel-Frames in den Follow-through: Ab ihnen darf die Figur laufen, springen oder angreifen, bevor sie ganz in Idle zurück ist. Bis Smash Melee gab es das nicht, und die Steuerung fühlte sich schwer und eng an (Breaking Down, ab 2:06). Die Pose im Cancel-Frame muss schon bereit aussehen (Follow-Throughs, ab 3:40). + + In der Testszene heißt das Fenster `cancel_from`. Mit `cancel_from = 6` darf ab dem sechsten Erholungsframe ein gepufferter Angriff starten. Im echten Lauf beginnt die Erholung in Frame 27, der Anschluss startet in Frame 32: + + ```text + 27 Spieler ACTIVE -> RECOVERY + 31 Druck attack + 32 Spieler RECOVERY -> WINDUP + ``` + + Ein Cancel macht noch keine Kombination. Mit Wind-up 8 geht die Hitbox des zweiten Angriffs in Frame 40 an. Nach demselben ersten Treffer ist der Dummy laut Log aber schon ab Frame 36 frei. Ein Gegner, der handeln kann, hätte 4 Frames, um auszuweichen. Ob ein Anschluss garantiert treffen soll, ob es ihn nur nach einem Treffer gibt und ab welchem Frame, entscheidest du für dein Spiel. + + > [!PRAXIS] + > Sakurais vier Angaben sind eine Schnittstelle zwischen Design und Animation. Cofino sagt in der Fragerunde seines Vortrags, dass er die Frame-Werte von Dreamscaper allein im Kopf behielt; in einem größeren Team müsse man sie anderen mitteilen. Für dich ist der Coding-Agent dieses Team. Schreib Hitbox-Frame, Gesamtframes und Abbruch-Frame in jede Vorgabe, mit der Zählweise dazu (siehe [Kampfmechanik als Vorgabe für einen Coding-Agenten](/combat/vorgaben)). + + ## Häufige Fehler + + - **Startup als Wind-up übernommen.** Startup 10 aus einer SF6-Tabelle als `windup = 10` eingetragen: Die Hitbox kommt einen Frame zu spät. Wind-up = Startup − 1. + - **Active 10-14 als 4 Frames gelesen.** Beide Grenzen gehören dazu, es sind 5. + - **Gesamtdauer mit Startup gerechnet.** Startup + Active + Recovery zählt den ersten aktiven Frame doppelt. + - **Erste Pose nicht gemessen.** Wer nur die Frames bis zur Hitbox zählt, übersieht, ob im ersten Frame nach dem Druck sichtbar etwas passiert. Sieht der Anfang aus wie Idle, hält der Spieler seine Eingabe leicht für verloren. + - **Die falsche Phase geändert.** Der Angriff wirkt träge, und du kürzt die Erholung, obwohl der späte Treffer stört. Erst messen, welche Phase gemeint ist. + - **Frame Advantage ohne Hitstop gerechnet.** Der Hitstop verlängert die aktive Phase im Log. Lies die Zustandswechsel ab, statt Platzhalter zu addieren. + - **Werte eines anderen Spiels übernommen.** SF6-Werte gehören zu SF6. Für dein Spiel sind sie Vergleichspunkte, keine Vorgabe. + + ## Zusammenfassung + + - Ein Angriff hat vier Phasen: Idle, Lead-in (Wind-up), Attack (aktive Phase), Follow-through (Erholung). Hitbox-Frame und Gesamtframes legen das Timing fest. + - Offizielle SF6-Daten zählen den ersten aktiven Frame zum Startup. Wind-up 8 entspricht Startup 9; Active 10-14 sind 5 Frames. + - Nach Sakurai erscheint die Hitbox nach etwa einem Drittel bis einem Viertel der Gesamtzeit. Die lange Erholung sieht der Spieler am längsten. + - Der Wind-up gibt Rückmeldung im ersten Frame, liefert Wucht und macht Gegnerangriffe lesbar. Sofort reagieren heißt nicht sofort treffen. + - Die Erholung ist der Preis eines Angriffs und das Punish-Fenster des Gegners. + - Frame Advantage ist die Differenz der ersten freien Frames. Im Testszene-Lauf ist der Angreifer nach einem Treffer bei −5. + - Ein Abbruchfenster verkürzt den Preis, macht aber noch keine garantierte Kombination. + - Physik-Frames bestimmen das Timing, Zeichnungen das Bild. Pixelart hat wenige davon. +exercises: + - id: vier-vorgaben + type: choice + title: Was Animatoren bei Sakurai bekommen + prompt: | + Sakurai gibt seinen Animatoren für jeden Angriff feste Angaben mit. Welche gehören dazu? + options: + - text: Die Idle-Pose + correct: true + why: Aus ihr startet jeder Angriff, und in sie kehrt er zurück. Sie ist die erste der vier Angaben. + - text: Die Angriffspose + correct: true + why: Das ist die Pose, in der die Hitbox erscheint. Der Hitstop friert sie ein, deshalb muss sie feststehen. + - text: Der Frame, in dem die Hitbox erscheint, und die Gesamtframes bis zum Abbruch + correct: true + why: Diese zwei Zahlen trennen Lead-in und Follow-through. Mit ihnen steht das Timing fest. + - text: Die Zahl der Zeichnungen für den Angriff + correct: false + why: Sakurai legt Zeitpunkte fest, nicht wie viele Bilder der Animator zeichnet. Wie er die Zeit füllt, ist Sache der Animation. + - text: Die Größe der Hitbox in Pixeln + correct: false + why: Animatoren sollen die Hitbox im Kopf haben, aber sie gehört nicht zu den vier Angaben. Die setzt bei Sakurai die Planung. + explanation: | + Die vier Angaben sind Idle-Pose, Angriffspose, Hitbox-Frame und Gesamtframes, also die Zeit bis zum Abbruch. Zwischen den ersten beiden liegt der Lead-in, zwischen Hitbox-Frame und Ende der Follow-through. So kann der Animator frei gestalten, und das Timing bleibt trotzdem fest. Für dich als Designer heißt das: Diese Werte gehören in jede Vorgabe, egal ob sie an einen Animator oder einen Coding-Agenten geht. + resources: + - title: Masahiro Sakurai · Breaking Down Attack Animations (ab 2:39) + url: https://www.youtube.com/watch?v=LewXWM7HDd8&t=159s + + - id: frame-vorteil-lesen + type: choice + title: Plus und minus + prompt: | + Aus den SF6-Daten von Ryu (Stand Oktober 2026): St. LP ist +4 on Hit und −1 on Block, St. HP ist +4 on Hit und −2 on Block. Welche Aussagen stimmen? + options: + - text: Nach einem geblockten St. LP ist der Gegner 1 Frame früher frei als Ryu. + correct: true + why: −1 on Block heißt, Ryu erholt sich einen Frame nach dem Ende des Blockstuns. + - text: Trifft St. HP, kann Ryu 4 Frames vor dem Gegner wieder handeln. + correct: true + why: +4 on Hit heißt, der Gegner steckt noch 4 Frames im Hitstun, wenn Ryu frei ist. + - text: +4 on Hit bedeutet, dass der Angriff 4 Frames lang treffen kann. + correct: false + why: Wie lange die Hitbox da ist, steht in der Spalte Active. Frame Advantage vergleicht die ersten freien Frames beider Figuren. + - text: Nach einem geblockten St. HP kann der Gegner ihn mit einem eigenen Angriff mit Startup 4 bestrafen. + correct: false + why: Der Gegner ist 2 Frames früher frei. Ein Angriff mit Startup 4 hat seine Hitbox erst im vierten Frame, dann ist Ryu schon frei. Bestrafen könnte ihn nur ein Angriff mit Startup 2 oder weniger. + explanation: | + Frame Advantage beschreibt, wer nach einem Treffer oder Block zuerst wieder handeln kann. Ein Plus heißt, der Angreifer ist früher frei, ein Minus, der Verteidiger. Ob ein Minus bestraft werden kann, hängt am schnellsten Angriff des Gegners: In der SF6-Zählung bestraft ein Angriff mit Startup 8 alles, was −8 oder schlechter ist. Reichweite spielt auch mit, deshalb ist das eine Rechnung unter der Annahme, dass der Gegner nah genug steht. + resources: + - title: Infil · Frame Advantage + url: https://glossary.infil.net/?t=Frame+Advantage + - title: Street Fighter 6 · Ryu Frame Data + url: https://www.streetfighter.com/6/character/ryu/frame + + - id: angriff-ablauf + type: order + title: Ein Angriff von vorn bis hinten + prompt: | + Bring die Ereignisse eines Angriffs mit Treffer und Abbruchfenster in die Reihenfolge, in der sie passieren. + lang: text + lines: | + Taste gedrückt, die Figur springt in die Ausholpose + Der Wind-up läuft, die Hitbox ist noch aus + Die Hitbox erscheint, die Angriffspose steht + Treffer, der Hitstop friert die Pose ein + Die Hitbox geht aus, die Erholung beginnt + Das Abbruchfenster öffnet sich + Die Figur steht wieder in der Idle-Pose + explanation: | + Der Posensprung kommt im ersten Frame, damit die Eingabe sofort sichtbar ist. Danach läuft der Rest des Wind-ups, bis die Hitbox erscheint. Der Hitstop setzt mit dem Treffer ein, also während der aktiven Phase. Das Abbruchfenster liegt in der Erholung, vor der fertigen Idle-Pose; genau das macht es nützlich, denn ab dort darf die Figur schon wieder handeln. + resources: + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 + + - id: sf6-aktive-frames + type: output + title: Wie lange trifft der schwere Schlag? + prompt: | + Ryus St. HP in Street Fighter 6 (Stand Oktober 2026). Wie viele Frames ist die Hitbox aktiv? Antworte mit der Zahl. + lang: text + code: | + Angriff Startup Active Recovery + St. HP 10 10-14 18 + expected: | + 5 + accept: + - 5 Frames + explanation: | + Active 10-14 heißt: Die Hitbox ist in den Frames 10, 11, 12, 13 und 14 da. Beide Grenzen zählen, also 14 − 10 + 1 = 5. Wer nur 14 − 10 rechnet, verliert einen Frame. Das ist derselbe Fehler wie bei `range(10, 14)` in Python, das die 14 nicht enthält. + resources: + - title: Street Fighter 6 · Ryu Frame Data + url: https://www.streetfighter.com/6/character/ryu/frame + + - id: sf6-gesamtdauer + type: output + title: Wann ist Ryu wieder frei? + prompt: | + Derselbe Angriff. Wie viele Frames dauert er insgesamt, vom ersten Frame nach dem Druck bis zum letzten Erholungsframe, wenn er nichts trifft? Antworte mit der Zahl. + lang: text + code: | + Angriff Startup Active Recovery + St. HP 10 10-14 18 + expected: | + 32 + accept: + - 32 Frames + hints: + - Rechne zuerst in die Zählung der Testszene um, also in Wind-up, aktiv und Erholung. + - Wind-up ist Startup − 1. + explanation: | + Wind-up 9 (Startup 10 minus den ersten aktiven Frame), aktiv 5, Erholung 18: 9 + 5 + 18 = 32 Frames, rund 533 ms. Wer Startup + 5 + 18 = 33 rechnet, zählt Frame 10 doppelt. Wer 10 + 18 = 28 rechnet, vergisst die übrigen aktiven Frames. Ohne Treffer gibt es keinen Hitstop, deshalb gilt die Rechnung hier unverändert. + resources: + - title: Infil · Startup + url: https://glossary.infil.net/?t=Startup + + - id: phase-vorhersagen + type: output + title: In welcher Phase ist die Figur? + prompt: | + Was gibt das Programm aus? Die Antwort ist eine Zeile. + lang: python + code: | + windup, aktiv, erholung = 6, 2, 10 + + def phase(f): + if f <= windup: + return "Wind-up" + if f <= windup + aktiv: + return "aktiv" + if f <= windup + aktiv + erholung: + return "Erholung" + return "frei" + + print(phase(7), phase(9), phase(19)) + expected: | + aktiv Erholung frei + explanation: | + Mit Wind-up 6 ist Frame 7 der erste aktive Frame. Die aktive Phase belegt die Frames 7 und 8, Frame 9 ist der erste Erholungsframe. Die Erholung reicht bis 6 + 2 + 10 = 18, Frame 19 ist frei. Die Vergleiche mit `<=` setzen genau die Zählung ab 1 um, die auch die Testszene nutzt. + + - id: vorteil-nach-hitstun + type: output + title: Mehr Hitstun, anderes Vorzeichen + prompt: | + Du stellst in der Testszene `hitstun` von 12 auf 18 (Platzhalter gegen Platzhalter), alles andere bleibt. Welchen Frame Advantage hat der Spieler nach dem Treffer? Antworte mit Vorzeichen, wie in Frame-Daten. + lang: text + code: | + Vorher (gemessen): + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + Nachher: + ?? Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + expected: | + +1 + accept: + - "1" + - +1 Frame + - 1 Frame + hints: + - Sechs Frames mehr Hitstun schieben `HITSTUN -> WAIT` um sechs Frames nach hinten. + - Frame Advantage = erster freier Frame des Ziels − erster freier Frame des Angreifers. + explanation: | + Der Dummy bleibt sechs Frames länger im Hitstun und ist erst ab Frame 42 frei. Der Spieler ist unverändert ab 41 frei. 42 − 41 = +1: Aus −5 wird +1. Hitstun ist damit eine der drei Größen, die den Frame Advantage verschieben; die anderen sind die Erholung des Angreifers und die aktiven Frames nach dem Treffer. Ob dein Spieler nach einem Treffer im Vorteil sein soll, ist eine Designentscheidung; diese Rechnung zeigt nur, wo sie sitzt. + resources: + - title: Infil · Hit Stun + url: https://glossary.infil.net/?t=Hit+Stun + + - id: sf6-umrechnen + type: gap + title: Vom SF6-Format in die Testszene + prompt: | + Trag Ryus St. MP (Stand Oktober 2026) in der Zählung der Testszene ein. Alle Werte in Frames. + lang: text + code: | + SF6 St. MP: Startup 6, Active 6-9, Recovery 11 + Testszene: + windup = ⟦5⟧ + active = ⟦4⟧ + recovery = ⟦11⟧ + gesamt = ⟦20⟧ + gaps: + - label: Wind-up + hint: Startup zählt den ersten aktiven Frame mit. + - label: Aktive Frames + hint: Von 6 bis einschließlich 9. + - label: Erholung + hint: Recovery zählt in beiden Formaten gleich. + - label: Gesamtdauer + hint: Wind-up + aktiv + Erholung. + explanation: | + Wind-up 6 − 1 = 5, aktiv 9 − 6 + 1 = 4, Erholung bleibt 11, zusammen 20 Frames. In der Testszene ginge die Hitbox mit diesen Werten in Frame 6 an, genau wie bei Startup 6. Nur die Schreibweise unterscheidet sich, das Timing ist gleich. Wer Werte aus Tabellen übernimmt, rechnet sie deshalb immer zuerst um. + resources: + - title: Street Fighter 6 · Ryu Frame Data + url: https://www.streetfighter.com/6/character/ryu/frame + + - id: frei-ab-falsch + type: bug + title: Eine Zeile widerspricht den anderen + prompt: | + Du schreibst die Frame-Daten des Platzhalterangriffs für eine Vorgabe auf. Der Agent baut danach, und im Log läuft die Figur einen Frame früher los, als die Erholung erlaubt. Finde die Zeile und korrigiere sie. + lang: text + code: | + Angriff A (Platzhalter der Testszene) + Wind-up 8 Frames, Frame 1–8 + aktiv 3 Frames, Frame 9–11 + Erholung 14 Frames, Frame 12–25 + Gesamt 25 Frames + frei ab Frame 25 + lines: [6] + fix: + - line: 6 + answers: + - frei ab Frame 26 + explanation: | + Die Erholung belegt die Frames 12 bis 25. In Frame 25 ist die Figur also noch gebunden, frei ist sie in Frame 26. Der Fehler kommt daher, dass Gesamtdauer und erster freier Frame verwechselt werden: 25 Frames dauert der Angriff, im 26. ist er vorbei. Ein Agent, der „frei ab Frame 25“ wörtlich nimmt, kürzt die Erholung um einen Frame, und niemand merkt es, bis jemand das Log nachzählt. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: vorteil-vorzeichen + type: bug + title: Plus statt minus + prompt: | + Das Modell soll den Frame Advantage des Spielers aus dem Testszene-Log ausgeben. Laut Log ist der Dummy ab Frame 36 frei und der Spieler erst ab 41, das Programm gibt aber `+5` aus. Finde die Zeile und korrigiere sie. + lang: python + code: | + # Testszene, Lauf mit Treffer + spieler_frei = 41 # RECOVERY -> MOVE + dummy_frei = 36 # HITSTUN -> WAIT + + def vorteil(angreifer, ziel): + return angreifer - ziel + + print(f"{vorteil(spieler_frei, dummy_frei):+d}") + lines: [6] + fix: + - line: 6 + answers: + - return ziel - angreifer + - return -(angreifer - ziel) + - return -angreifer + ziel + explanation: | + Frame Advantage ist positiv, wenn der Angreifer **früher** frei ist, also eine kleinere Frame-Nummer hat. Deshalb rechnet man erster freier Frame des Ziels minus erster freier Frame des Angreifers: 36 − 41 = −5. Vertauschte Reihenfolge dreht das Vorzeichen, und aus einem Nachteil wird auf dem Papier ein Vorteil. Prüf solche Modelle immer an einem Log, dessen Ergebnis du kennst. + resources: + - title: Infil · Frame Advantage + url: https://glossary.infil.net/?t=Frame+Advantage + + - id: figur-gegen-gegner + type: explain + title: Schnelle Figur, langsame Gegner + prompt: | + Erkläre in wenigen Sätzen, warum Mariel Cartwright die Spielfigur und die Gegner beim Wind-up unterschiedlich behandelt und was Dreamscaper daraus für die eigenen Angriffe ableitet. + lang: text + code: | + Spielfigur reagiert fast sofort auf den Druck + Gegner langer Wind-up vor dem Treffer + Dreamscaper Spielerangriffe schneller als + Gegnerangriffe + points: + - Die Spielfigur muss auf den Druck sofort sichtbar reagieren, sonst wirkt die Steuerung träge. + - Der Wind-up eines Gegners ist die Zeit, in der der Spieler erkennen und ausweichen, blocken oder weglaufen kann. + - Dreamscaper macht Spielerangriffe schneller als Gegnerangriffe, damit der Spieler den Startup eines Gegners unterbrechen kann. + - Die Asymmetrie ist eine Designentscheidung; die Frame-Zahlen machen sichtbar, wer wann reagieren kann. + explanation: | + Für die Spielfigur zählt Rückmeldung: Ein Druck, auf den nichts passiert, fühlt sich wie ein verlorener Druck an. Für Gegner zählt Lesbarkeit: Ihr Wind-up ist die Reaktionszeit des Spielers. Cartwright trennt deshalb die beiden Seiten. Ian Cofino beschreibt für Dreamscaper die Folge: Weil Spielerangriffe schneller sind, kann der Spieler einen Gegner in dessen Startup treffen. Wie groß der Unterschied in deinem Spiel ist, entscheidest du; die Frames zeigen dir, wer wann eingreifen kann. + resources: + - title: Mariel Cartwright · Animation Bootcamp (GDC 2014) + url: https://www.youtube.com/watch?v=Mw0h9WmBlsw + - title: Ian Cofino · Dreamscaper (GDC 2022) + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + + - id: clip-follow-through + type: practice + title: "Clip-Analyse: Wie lang ist der Follow-through?" + prompt: | + Sieh dir Sakurais [Follow-Throughs Make the Impact](https://www.youtube.com/watch?v=cIB0BUe6Ihk) an. Ab etwa 1:24 zeigt er Zeitleisten vieler Smash-Angriffe. Halte das Video bei drei Angriffen an und notiere für jeden, wann die Hitbox erscheint und wann die Figur wieder handeln kann. Lässt sich keine Zahl ablesen, schätze den Anteil an der Länge des Balkens. Rechne danach für jeden Angriff den Anteil bis zur Hitbox aus und vergleiche ihn mit Sakurais Faustregel von einem Drittel bis einem Viertel. + lang: text + solution: | + Beispiel für die Form, mit dem Platzhalterangriff + der Testszene statt eines Smash-Angriffs: + + Angriff Hitbox ab frei ab Anteil + Testszene A Frame 9 Frame 26 9/25 = 0,36 + + Beobachtung: knapp über einem Drittel. + Frage an mich: Wirkt die Erholung lang genug, + damit man den Schlag noch sieht? + checklist: + - Du hast drei Angriffe notiert, jeweils mit Hitbox-Zeitpunkt und Ende. + - Du hast vermerkt, ob du Zahlen abgelesen oder Anteile geschätzt hast. + - Für jeden Angriff steht ein Anteil bis zur Hitbox da. + - Du hast notiert, welcher Angriff am weitesten von der Faustregel abweicht, und eine Vermutung, warum. + - Deine Notiz beschreibt Smash; sie ist keine Vorgabe für dein Spiel. + explanation: | + Die Faustregel beschreibt Smash, ein Kampfspiel mit schnellen Reaktionen und viel Kräftemessen. Ein Angriff mit langer Erholung ist dort ein Risiko, das man bewusst eingeht. Wenn du die Anteile selbst siehst, erkennst du später in jedem Spiel, ob ein Angriff vorn oder hinten lang ist. Abweichungen sind keine Fehler, sie zeigen, welche Rolle ein Angriff hat. + resources: + - title: Masahiro Sakurai · Follow-Throughs Make the Impact + url: https://www.youtube.com/watch?v=cIB0BUe6Ihk + + - id: angriff-beschreiben + type: practice + title: Einen Angriff in vier Angaben beschreiben + prompt: | + Beschreib einen Angriff, den du dir für dein Spiel vorstellst, in Sakurais vier Angaben: Idle-Pose, Angriffspose, Hitbox-Frame, Gesamtframes. Ergänze Abbruch-Frame (oder „keiner“), was der Spieler im ersten Frame nach dem Druck sieht und was der Gegner während des Wind-ups erkennen soll. Schreib **vor** jedem Test auf, was du erwartest. Die Werte wählst du selbst; sie sind Platzhalter, bis du sie gespielt hast. + lang: text + solution: | + Beispiel für die Form, nicht für die Werte. + Angriff des neutralen Trainingsgegners, + Platzhalter (Wind-up 20, aktiv 4, Erholung 30): + + Idle-Pose: steht + Angriffspose: Quadrat leuchtet rot + Hitbox-Frame: 21 (Wind-up 20) + Gesamtframes: 54, frei ab 55 + Abbruch: keiner + Frame 1: gelbe Fläche erscheint + Wind-up zeigt: gelbe Bodenfläche, 20 Frames + Anteil: 21/54 ≈ 0,39 + Erwartung: ‹vor dem Test aufschreiben› + checklist: + - Alle vier Angaben stehen da, mit Frames gezählt ab 1 und der Angabe, ob Wind-up oder Startup gemeint ist. + - Du hast den Anteil bis zur Hitbox ausgerechnet. + - Du hast in einem Satz beschrieben, was im ersten Frame nach dem Druck zu sehen ist. + - Jeder Wert ist als Platzhalter markiert, und keiner ist aus einer SF6- oder Smash-Tabelle als Ziel übernommen. + - Deine Erwartung stand fest, bevor du etwas ausprobiert hast. + explanation: | + Die Übung trainiert die Form, in der du Angriffe künftig aufschreibst. Sie hat keine richtige Lösung, weil sie eine Designentscheidung ist. Wichtig ist, dass jeder Wert eine Zählweise hat, dass Rückmeldung und Treffer getrennt beschrieben sind und dass deine Erwartung vor dem Test steht. Dann kannst du beim Spielen in der Testszene unterscheiden, ob der Wert nicht passt oder deine Erwartung. Die Notiz ist auch der Anfang einer Vorgabe für einen Coding-Agenten. + resources: + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 diff --git a/content/combat/modules/ausweichen.yaml b/content/combat/modules/ausweichen.yaml new file mode 100644 index 0000000..8f1fb66 --- /dev/null +++ b/content/combat/modules/ausweichen.yaml @@ -0,0 +1,503 @@ +id: ausweichen +title: Ausweichen, Risiko und Belohnung +level: 2 +minutes: 110 +summary: Ein Ausweichen in Frames zerlegen – Anlauf, i-Frames, Weg, Erholung –, aus einem Log ablesen, ob ein Druck rechtzeitig war, und beurteilen, was ein Ausweichen kostet und was es einbringt. +goals: + - Du zerlegst ein Ausweichen in Anlauf, i-Frames, Weg und Erholung und rechnest die Phasen in Frames und Pixel um. + - Du sagst aus einem Log vorher, ob ein Druck rechtzeitig war und in welchem Frame die i-Frames enden. + - Du unterscheidest Schutz durch i-Frames von Schutz durch Abstand und erkennst, wann beide aneinander hängen. + - Du bestimmst nach einer Aktion, wer zuerst wieder frei ist, und berechnest daraus ein Punish-Fenster. + - Du beschreibst Risiko und Belohnung eines Ausweichens an veröffentlichten Spielen, ohne Werte für dein eigenes festzulegen. +resources: + - title: Melee AI in The Last of Us Part II (GDC 2021, Folien) + url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf + - title: Hades II · Early Access Patch 1 (Shacknews) + url: https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1 + - title: GOG Interview · The Game Bakers über Furi + url: https://gog.com/news/gog_interview_the_game_bakers_and_furis_success + - title: Q&A · Enter the Gungeon (Game Developer) + url: https://www.gamedeveloper.com/design/q-a-the-guns-and-dungeons-of-i-enter-the-gungeon-i- +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Der Trainingsgegner der Testszene (im Log `Dummy`) kündigt seinen Angriff 20 Frames lang mit einer gelben Bodenfläche an, in Frame 30 geht seine Hitbox an. „Druck in Frame N“ heißt in diesem Modul: Die Zeile `Druck dodge` steht in Frame N im Log, die Figur hat den Druck in diesem Frame gesehen. Kommt der Druck in Frame 29, steht im Log kein Treffer. Kommt er in Frame 30, steht dort `Spieler getroffen in DODGE, Frame 1`. Ein Frame ist ein Physik-Tick, 1/60 Sekunde. Zwischen „ausgewichen“ und „getroffen“ liegen also 16,7 Millisekunden, und das Log zeigt, woran es lag. + + Ausweichen gehört zum Kern vieler Actionspiele. In Enter the Gungeon war die Ausweichrolle laut Dave Crooks „the first mechanic other than shooting that went into the game“, angelehnt an die Rolle mit i-Frames aus der Souls-Reihe ([Game Developer](https://www.gamedeveloper.com/design/q-a-the-guns-and-dungeons-of-i-enter-the-gungeon-i-)). Dieses Modul zerlegt ein Ausweichen in Frames, trennt zwei Arten von Schutz und fragt, was ein Ausweichen kosten muss, damit es eine Entscheidung bleibt. Alle Werte der Testszene sind Platzhalter. Welche Werte dein Spiel bekommt, entscheidest du. + + ## Anatomie eines Ausweichens in Frames + + Ein Ausweichen hat vier Teile: + + - **Anlauf**: Die Figur weicht schon aus, ist aber noch verwundbar. + - **i-Frames**: Frames, in denen die Figur nicht getroffen werden kann. + - **Weg**: Die Figur bewegt sich. Der Weg kann länger dauern als die i-Frames. + - **Erholung**: Die Figur steht, bis sie wieder frei ist. + + Die Spielfigur der Testszene hat dafür fünf Felder mit Platzhalterwerten: `dodge_frames = 18`, `invulnerable_from = 2`, `invulnerable_to = 12`, `dodge_speed = 220` (Pixel pro Sekunde) und `dodge_recovery = 8`. Zustände zählen ihre Frames ab 1. Daraus ergibt sich diese Zeitleiste: + + ```text + Frame 1 Anlauf, verwundbar + Frame 2–12 i-Frames (11 Frames) + Frame 13–18 in Bewegung, verwundbar + Frame 19–26 Erholung, steht still + Frame 27 wieder frei + ``` + + Der Weg dauert 18 Frames: 220 px/s × 18 / 60 = 66 Pixel. Im Bild von 320 × 180 Pixeln ist das ein Fünftel der Breite. Die Figur ist 26 Frames gebunden und in 11 davon geschützt. + + Eine Funktion entscheidet, ob die Figur gerade geschützt ist. In diesen Frames schaltet sie ihre Hurtbox ab, die Fläche, an der sie getroffen werden kann. Hier umbrochen, damit sie aufs Handy passt: + + ```gdscript + func is_invulnerable() -> bool: + return (state == State.DODGE + and frame >= invulnerable_from + and frame <= invulnerable_to) + ``` + + Damit lässt sich das Log vom Anfang erklären. Die Hitbox des Dummys geht in Frame 30 an, und Godot prüft die Überlappung am Ende dieses Frames. Gedrückt in Frame 29, ist die Figur in Frame 30 im zweiten Frame ihres Ausweichens und geschützt. Gedrückt in Frame 30, ist sie im ersten, dem Anlauf. Das Treffer-Signal kommt am Anfang von Frame 31, deshalb steht der Treffer dort. Der gelungene Lauf, in Godot 4.7.2 gemessen: + + ```text + 29 Druck dodge + 29 Spieler MOVE -> DODGE + 30 Spieler i-Frames an (Frame 2 des Ausweichens) + 30 Dummy TELEGRAPH -> ACTIVE + 34 Dummy ACTIVE -> RECOVERY + 41 Spieler i-Frames aus (Frame 13 des Ausweichens) + ``` + + Ob dein Ausweichen einen Anlauf hat und wie lang er ist, entscheidest du. Beim Lesen eines Logs musst du nur wissen, wo er steht. + + ## i-Frames oder aus der Fläche laufen + + Ein Angriff verfehlt dich auf zwei Arten: Du bist im Prüf-Frame unverwundbar, oder du stehst nicht mehr in seiner Fläche. In einem Spiel in der Draufsicht mit analoger 360°-Bewegung gibt es beides ständig. Mit `move_speed = 90` läuft die Figur 1,5 Pixel pro Frame. In den 20 Frames des Telegraphs kommt sie zu Fuß 30 Pixel weit, mit dem Ausweichen in 18 Frames 66 Pixel. + + In der Testszene hängen beide Arten von Schutz nicht zusammen. `dodge_speed` ändert den Weg, `invulnerable_from` und `invulnerable_to` die geschützten Frames. In Hades II hängen sie aneinander. Die Patchnotes zum ersten Early-Access-Patch sagen über die Karte The Swift Runner, sie mache den Dash jetzt „immediate“, mit dem Zusatz: „note the faster speed means you are invulnerable for very slightly less time“ ([Shacknews](https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1)). Ein schnellerer Dash schützt dort kürzer. + + Wenn ein Tester sagt, das Ausweichen „schütze nicht“, prüf zuerst, welche Art von Schutz gemeint ist. Wer durch einen Angriff hindurchweicht, braucht i-Frames. Wer an ihm vorbeiweicht, braucht Weg. Im gemessenen Lauf mit Druck in Frame 28 trägt das Ausweichen die Figur 66 Pixel weit am Dummy vorbei. + + Bleibt die Figur in der Fläche, müssen die i-Frames jeden aktiven Frame der Hitbox abdecken. Das kleine Modell prüft genau das. Es passt zu den Läufen der Testszene, auch zu einem Lauf mit `dodge_speed = 0`, in dem die Figur am Ort ausweicht: Druck in Frame 21, i-Frames aus in Frame 33, Treffer in Frame 34, weil die Hitbox bis Frame 33 aktiv ist. + + ```python run + VON, BIS = 2, 12 # i-Frames, Platzhalter + AN, AKTIV = 30, 4 # Hitbox des Dummys + + def geschuetzt(start, frame): + n = frame - start + 1 # im Ausweichen + return VON <= n <= BIS + + def ausgewichen(start): + frames = range(AN, AN + AKTIV) + return all(geschuetzt(start, f) + for f in frames) + + for start in (21, 28, 29, 30): + print(start, ausgewichen(start)) + ``` + + Die Ausgabe passt zu den gemessenen Logs: Frame 21 zu früh, 28 und 29 rechtzeitig, 30 zu spät. Ändere `BIS` oder `AKTIV` und sieh nach, wie sich das Fenster verschiebt. + + ## Commitment: wie lange dich das Ausweichen bindet + + Commitment heißt, wie fest der Spieler nach dem Drücken an seine Aktion gebunden ist. In der Testszene sind es 26 Frames, in denen weder ein Angriff noch ein zweites Ausweichen startet. Ausweichen startet nur aus `MOVE`, also wenn die Figur frei ist. Ein Druck kurz vor dem Ende der Erholung eines Angriffs geht trotzdem nicht verloren: Der Eingabepuffer (`input_buffer = 6`, Platzhalter) hält ihn, und das Ausweichen startet im ersten freien Frame, wenn der Druck dann höchstens 6 Frames alt ist. Mehr dazu in [Eingabe: Reaktionszeit, Latenz und Puffer](/combat/eingabe). + + Viele Spiele erlauben mehr: ein Abbruchfenster (Cancel), in dem das Ausweichen die Erholung eines Angriffs abbricht. Supergiant hat im ersten Patch von Hades II genau daran gearbeitet: „You now can Dash out of many attack recovery animations more reliably“, so die von Shacknews wiedergegebenen Patchnotes ([Shacknews](https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1)). Jedes solche Fenster senkt das Commitment eines Angriffs. Darf man aus 14 Frames Erholung ab ihrem vierten Frame wegrollen, bindet sie nur noch 3 Frames. Wie Abbruchfenster in Frames aussehen, steht in [Angriffsphasen](/combat/angriffsphasen). + + Commitment hängt auch an der Eingabe. In Furi startet der Dash laut Emeric Thoa „when you ‚release‘ the button, not when you ‚press‘“. Halten lädt den Dash auf, damit er weiter trägt ([Game Developer](https://www.gamedeveloper.com/design/furi-some-tough-design-decisions-striving-to-make-a-memorable-game)). Die Entscheidung fällt dort beim Loslassen. + + > [!MERKE] + > Ein Ausweichen besteht aus vier Zahlen: wann der Schutz beginnt, wann er endet, wie weit die Figur kommt und wann sie wieder frei ist. Wer sie getrennt misst, kann jede Beschwerde einer Zahl zuordnen. + + ## Risiko und Belohnung: was das Ausweichen kostet und bringt + + Kostet ein Ausweichen nichts, ist es in jeder Lage eine sichere Wahl, und andere Verteidigungen verlieren ihren Grund. The Game Bakers nennen „fair“ als tägliches Stichwort für Furi: Wer getroffen wurde, sollte einen Fehler gemacht oder „a too big risk“ genommen haben ([GOG](https://gog.com/news/gog_interview_the_game_bakers_and_furis_success)). Risiko und Belohnung hängen dort an zwei Verteidigungen. Ein erfolgreicher Parry heilt die Figur, der Dash ist leichter, heilt aber nicht. Die sichere Option bringt weniger. + + Sekiro verschiebt das Gewicht weiter. Masaru Yamamura von FromSoftware erklärt, wie man die Haltung (Posture) der Gegner abbaut: „by deflecting their momentum and their attacks“ ([Game Informer](https://gameinformer.com/2019/01/15/how-sekiro-plays-differently-than-dark-souls)). Game Informer folgert, dass Spieler, die bei der ersten Bewegung eines Gegners ausweichen, das Spiel für schwerer halten, als es ist. Ein gesehener Angriff ist dort eine Gelegenheit, kein Signal zur Flucht. + + Für dein Spiel heißt das nicht, dass du einen Parry brauchst. Es heißt, dass du Kosten und Belohnung benennen kannst. Kosten sind etwa gebundene Frames, ein Anlauf ohne Schutz, ein Weg, der dich aus der eigenen Angriffsreichweite trägt, oder eine Ressource. Belohnungen sind etwa ein Punish-Fenster, Heilung, Schaden oder eine bessere Position. + + ## Sichere und unsichere Aktionen: wer zuerst wieder frei ist + + Ein Punish ist ein Treffer in die Erholung des Gegners. Ob er geht, hängt an einer Frage: Wer kann nach einer Aktion zuerst wieder handeln? Das Angriffs-Log der Testszene (Wind-up 8, aktiv 3, Erholung 14, Hitstop 4, Hitstun 12) beantwortet sie für einen Treffer: + + ```text + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body, … + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + ``` + + Der Dummy ist ab Frame 36 frei, die Figur erst ab 41. Nach diesem Treffer ist der Angreifer 5 Frames im Nachteil. Hätte der Dummy einen schnellen Angriff, könnte er die Figur in ihrer Erholung treffen. So ein Angriff heißt unsicher. Sicher ist er, wenn der Angreifer spätestens zusammen mit dem Ziel frei ist. Die Differenz hängt am Hitstun des Ziels, den Frames, in denen es nicht handeln kann, an der Erholung des Angreifers und daran, wie viele aktive Frames nach dem Treffer noch laufen. Hier sind es 2 aktive Frames plus 14 Erholung gegen 11 Frames, die der Dummy nach dem Hitstop noch im Hitstun steckt: 2 + 14 − 11 = 5. Mit Hitstun 15 ist der Dummy erst ab Frame 39 frei, und der Nachteil schrumpft auf 2 Frames (in Godot 4.7.2 gemessen). + + Dasselbe gilt fürs Ausweichen. Im gemessenen Lauf mit Druck in Frame 28 ist die Figur ab Frame 54 frei. Der Dummy hat 30 Frames Erholung (Platzhalter) und steckt bis Frame 63 darin: + + ```text + 28 Spieler MOVE -> DODGE + 34 Dummy ACTIVE -> RECOVERY + 46 Spieler DODGE -> DODGE_RECOVERY + 54 Spieler DODGE_RECOVERY -> MOVE + 64 Dummy RECOVERY -> WAIT + ``` + + Das Punish-Fenster ist 10 Frames breit, von Frame 54 bis 63. Ob ein Angriff hineinpasst, hängt an seinem Wind-up und daran, wo die Figur nach dem Ausweichen steht. 66 Pixel am Gegner vorbei können ihn aus der eigenen Reichweite bringen. + + > [!PRAXIS] Schutz-Frames als Daten + > Naughty Dog speichert in The Last of Us Part II zu Nahkampfangriffen Ereignisse wie „Hit frames“, „Target tracking“ und „Invincibility“ ([GDC-Folien](https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf)). Treffer und Schutz stehen als Daten neben der Animation. Die Testszene macht dasselbe im Kleinen: `AttackData` für Angriffe, fünf Felder für das Ausweichen. Wer Werte als Daten führt, kann sie vergleichen, protokollieren und einem Coding-Agenten als Vorgabe geben. + + ## Lesbare Telegraphs machen Ausweichen erst möglich + + Ausweichen nützt nur, wenn der Spieler den Angriff kommen sieht. Ming-Lun „Allen“ Chou fasst den Nahkampf früherer Naughty-Dog-Spiele so zusammen: „Short enemy attack tells. No player dodge. Enemy starts swinging = certain hit.“ Für The Last of Us Part II drehte das Team alle drei Punkte um: „Longer enemy attack tells. More obvious enemy attack tells. Player can now dodge.“ Ein Ziel war „Skilled players can survive unscathed.“ ([GDC-Folien](https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf)). + + Ein Telegraph ist die Ankündigung eines Angriffs, in der Testszene die gelbe Bodenfläche. Mit 20 Frames Telegraph, 4 aktiven Frames und 11 i-Frames reicht ein Druck am Ort zwischen Frame 22 und 29. Das sind 8 Frames, nicht 20: Die i-Frames müssen alle 4 aktiven Frames abdecken, also 11 − 4 + 1 = 8. Der Rest des Telegraphs ist Zeit zum Erkennen und Entscheiden. Wie viel davon ein Mensch braucht, steht in [Eingabe: Reaktionszeit, Latenz und Puffer](/combat/eingabe). Wie man Telegraphs gestaltet, behandelt [Gegnermuster und Telegraphing](/combat/gegnermuster). + + Zum Vergleich: Sieh dir den Clip [What Makes a Good Combat System?](https://www.youtube.com/watch?v=8X4fx-YncqA) von Game Maker's Toolkit an. Achte bei jedem Ausweichen, das du siehst, darauf, was der Spieler vorher sehen konnte. + + > [!INTERVIEW] Designtest: „Das Ausweichen fühlt sich nutzlos an“ + > So eine Rückmeldung kommt in Playtests und in Designtests bei Studios. Gute Antwort: erst trennen, dann ändern. Im Log steht, in welchem Frame des Ausweichens der Treffer kam. Frame 1 heißt Anlauf. Ein Frame nach den i-Frames heißt, der Schutz war zu früh vorbei. Ein Treffer außerhalb des Ausweichens heißt, die Figur war noch gebunden oder der Spieler hat zu spät reagiert. Danach fragst du: Ist der Telegraph lang und deutlich genug? Darf man aus der Erholung ausweichen? Welche Zahl du dann änderst, ist eine Designentscheidung. Und du änderst nur eine. + + ## Häufige Fehler + + - **„Das Ausweichen ist kaputt“, ohne ins Log zu sehen.** Erst den Frame des Ausweichens ablesen, in dem der Treffer kam. Frame 1 ist kein Fehler, sondern der Anlauf. + - **Den Prüf-Frame vergessen.** Die Hitbox geht in Frame 30 an, der Treffer steht in Frame 31. Wer vom Treffer-Frame aus rechnet, verschiebt jedes Fenster um eins. + - **i-Frames und Weg verwechseln.** Ein Ausweichen, das gegen einen kleinen Angriff schützt, schützt in einer großen Fläche vielleicht nicht. Teste i-Frames in einer Fläche, die du nicht verlassen kannst. + - **Geschwindigkeit ändern und denselben Schutz erwarten.** In Hades II verkürzt ein schnellerer Dash die Unverwundbarkeit. Prüf in deinem Code, ob beides zusammenhängt. + - **Telegraph und Ausweichen nicht zusammen nachrechnen.** Ist ein Telegraph kürzer als Reaktion plus Latenz, kann man nur aus dem Muster ausweichen. Ob das gewollt ist, entscheidest du; nachrechnen solltest du es in jedem Fall. + - **Die Kosten des Ausweichens nicht messen.** Erholung, Weg und Punish-Fenster stehen im Log. Wer sie nicht misst, merkt nicht, wenn das Ausweichen still zur Antwort auf alles wird. + - **Zwei Werte auf einmal ändern.** Änderst du i-Frames und Erholung gleichzeitig, weißt du nicht, welche Änderung du spürst. Wie man sauber vergleicht, steht in [Tuning als Experiment](/combat/tuning). + + ## Zusammenfassung + + - Ein Ausweichen hat Anlauf, i-Frames, Weg und Erholung. Mit den Platzhaltern: verwundbar in Frame 1, geschützt in 2 bis 12, frei ab Frame 27, 66 Pixel Weg. + - Der Schutz zählt im Frame, in dem die Hitbox angeht. Der Treffer steht einen Frame später im Log. + - Bleibt die Figur in der Fläche, müssen die i-Frames alle aktiven Frames abdecken. Das Fenster ist i-Frames minus aktive Frames plus eins breit. + - i-Frames schützen beim Durchweichen, Weg beim Vorbeiweichen. In Hades II hängt die Unverwundbarkeit an der Dash-Geschwindigkeit. + - Abbruchfenster entscheiden, ob man aus der Erholung ausweichen kann. Hades II hat das im ersten Patch erleichtert. + - Risiko und Belohnung gehören zusammen: In Furi heilt der Parry, der leichtere Dash nicht. + - Wer nach einer Aktion zuerst frei ist, entscheidet über den Punish: nach dem Treffer im Log 5 Frames Nachteil, nach dem Ausweichen 10 Frames Fenster. + - Auf einen Angriff reagieren kann nur, wer ihn rechtzeitig sieht. Naughty Dog hat die Tells verlängert, als das Ausweichen dazukam. +exercises: + - id: schutz-oder-weg + type: choice + title: Eine Fläche, aus der du nicht herauskommst + prompt: | + Ein Gegner trifft eine Fläche, die die Figur während eines Ausweichens nicht verlassen kann. Welche Aussagen stimmen? + options: + - text: Nur die i-Frames entscheiden, ob die Figur getroffen wird. + correct: true + why: Der Weg führt nicht hinaus. Es zählt nur, ob jeder aktive Frame der Hitbox in die i-Frames fällt. + - text: Dauert die aktive Phase länger als die i-Frames, gibt es keinen Druck, der am Ort reicht. + correct: true + why: Die i-Frames müssen jeden aktiven Frame abdecken. Bei 11 i-Frames und 12 aktiven Frames bleibt immer ein ungeschützter Frame. + - text: Ein schnelleres Ausweichen schützt hier immer besser. + correct: false + why: Mehr Weg nützt nur, wenn er aus der Fläche führt. In Hades II verkürzt ein schnellerer Dash sogar die Unverwundbarkeit. + - text: Am sichersten ist ein Druck im ersten Frame des Telegraphs. + correct: false + why: Dann enden die i-Frames, bevor die Hitbox angeht oder während sie noch aktiv ist. Zu früh ist genauso falsch wie zu spät. + explanation: | + Bleibt die Figur in der Fläche, ist Ausweichen eine reine Frage der i-Frames. Das Fenster ist i-Frames minus aktive Frames plus eins breit. Ist es null oder kleiner, hilft am Ort kein Druck, nur Abstand. Deshalb lohnt es sich, bei jeder Beschwerde zu klären, ob die Figur durch den Angriff hindurch oder an ihm vorbei wollte. + resources: + - title: Hades II · Early Access Patch 1 (Shacknews) + url: https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1 + + - id: risiko-belohnung-quellen + type: choice + title: Was die Studios sagen + prompt: | + Welche Aussagen geben die Quellen der Lektion richtig wieder? + options: + - text: In Furi heilt ein erfolgreicher Parry die Figur, der Dash ist leichter, heilt aber nicht. + correct: true + why: So beschreiben es The Game Bakers im GOG-Interview als Teil ihres Risiko-Belohnungs-Gedankens. + - text: Yamamura erklärt, dass man in Sekiro die Haltung der Gegner abbaut, indem man ihre Angriffe ablenkt. + correct: true + why: „you can wear down their posture by deflecting their momentum and their attacks“, sagt er bei Game Informer. + - text: Laut Supergiant macht Swift Runner den Dash in Hades II schneller und verlängert die Unverwundbarkeit. + correct: false + why: Die Patchnotes sagen das Gegenteil, die Figur ist „very slightly less time“ unverwundbar. + - text: In Furi startet der Dash beim Drücken der Taste, damit er ohne Verzögerung kommt. + correct: false + why: Laut Emeric Thoa startet er beim Loslassen. Halten lädt ihn auf, damit er weiter trägt. + explanation: | + Alle drei Spiele verbinden Ausweichen mit einem Preis. In Furi ist der Dash die leichte, aber weniger lohnende Wahl. In Sekiro wird der gesehene Angriff zur Gelegenheit. In Hades II tauscht Swift Runner Geschwindigkeit gegen Schutzdauer. Wenn du Quellen zitierst, prüf die Richtung einer Aussage: „less time“ und „more time“ führen zu entgegengesetzten Schlüssen. + resources: + - title: GOG Interview · The Game Bakers über Furi + url: https://gog.com/news/gog_interview_the_game_bakers_and_furis_success + - title: Game Informer · How Sekiro Plays Differently Than Dark Souls + url: https://gameinformer.com/2019/01/15/how-sekiro-plays-differently-than-dark-souls + + - id: diagnose-reihenfolge + type: order + title: Von der Beschwerde zur Änderung + prompt: | + Ein Tester sagt: „Ich weiche aus und werde trotzdem getroffen.“ Bring die Schritte in eine sinnvolle Reihenfolge. „Phase“ heißt hier: Anlauf, nach den i-Frames oder gar nicht im Ausweichen. + lang: text + lines: | + Situation mit Log nachstellen + Treffer-Zeile suchen + Frame des Ausweichens ablesen + Frame einer Phase zuordnen + Größe und Erwartung notieren + Nur diese Größe ändern + Mit dem alten Wert vergleichen + explanation: | + Erst messen, dann deuten, dann ändern. Der Frame in der Treffer-Zeile sagt, ob der Anlauf, das Ende der i-Frames oder etwas ganz anderes schuld war, etwa eine Figur, die noch in der Erholung eines Angriffs steckte. Erst danach ist klar, welche Größe überhaupt in Frage kommt. Die Erwartung schreibst du vor der Änderung auf, damit der Vergleich etwas prüft. + resources: + - title: Valve's Approach to Playtesting (GDC 2009) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: iframes-aus-frame + type: output + title: Wann endet der Schutz? + prompt: | + Platzhalter: i-Frames von Frame 2 bis 12 des Ausweichens. Die Figur sieht den Druck in Frame 27. In welchem Physik-Frame steht `Spieler i-Frames aus`? Antworte mit der Zahl. + lang: text + code: | + 27 Druck dodge + 27 Spieler MOVE -> DODGE + 28 Spieler i-Frames an (Frame 2 des Ausweichens) + ?? Spieler i-Frames aus (Frame 13 des Ausweichens) + expected: | + 39 + accept: ["Frame 39"] + hints: + - Frame 27 ist Frame 1 des Ausweichens. + explanation: | + Frame 27 ist der erste Frame des Ausweichens, Frame 28 der zweite. Frame 13 des Ausweichens ist also 27 + 12 = 39. Dort geht die Hurtbox wieder an. In Godot 4.7.2 gemessen; die Läufe mit Druck in 28 und 29 zeigen dasselbe Muster mit 40 und 41. + + - id: fenster-am-ort + type: output + title: Das Fenster, wenn die Figur stehen bleibt + prompt: | + Das Modell aus der Lektion sucht alle Druck-Frames, bei denen eine Figur am Ort ausweicht. Was gibt es aus? + lang: python + code: | + VON, BIS = 2, 12 # i-Frames + AN, AKTIV = 30, 4 # Hitbox + + def geschuetzt(start, frame): + n = frame - start + 1 + return VON <= n <= BIS + + def ausgewichen(start): + frames = range(AN, AN + AKTIV) + return all(geschuetzt(start, f) + for f in frames) + + ok = [s for s in range(1, AN + 1) + if ausgewichen(s)] + print(ok[0], ok[-1], len(ok)) + expected: | + 22 29 8 + hints: + - Der letzte aktive Frame ist 33. Dort darf die Figur höchstens in Frame 12 des Ausweichens sein. + explanation: | + Im ersten aktiven Frame, 30, muss die Figur mindestens in Frame 2 sein: 30 − start + 1 ≥ 2, also start ≤ 29. Im letzten, 33, höchstens in Frame 12: 33 − start + 1 ≤ 12, also start ≥ 22. Das sind 8 Frames, i-Frames minus aktive Frames plus eins. Die Ränder passen zur Testszene: Mit `dodge_speed = 0` trifft ein Druck in Frame 21 in Frame 34, einer in 22 nicht mehr. + resources: + - title: Python · all() + url: https://docs.python.org/3/library/functions.html#all + + - id: nachteil-mit-hitstun + type: output + title: Drei Frames mehr Hitstun + prompt: | + Das Log zeigt einen Treffer mit Hitstun 12. Du stellst `hitstun` auf 15, alles andere bleibt gleich. Um wie viele Frames ist der Angreifer nach dem Treffer im Nachteil? Antworte mit der Zahl. + lang: text + code: | + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + expected: | + 2 + accept: ["2 Frames"] + hints: + - Hitstun verlängert nur die Zeit des Dummys. + explanation: | + Hitstun hält nur das Ziel fest. Drei Frames mehr verschieben `HITSTUN -> WAIT` von 36 auf 39. Die Erholung des Angreifers hängt nicht daran, er bleibt bei 41. 41 − 39 = 2 Frames Nachteil statt 5. In Godot 4.7.2 gemessen. Ob ein Angriff in deinem Spiel sicher sein soll, ist eine Designentscheidung; die Rechnung zeigt nur, welche Werte darüber bestimmen: Hitstun, Erholung und die aktiven Frames nach dem Treffer. + + - id: zeitleiste-luecken + type: gap + title: Die Zeitleiste ergänzen + prompt: | + Platzhalter: `dodge_frames = 18`, i-Frames 2 bis 12, `dodge_speed = 220`, `dodge_recovery = 8`. Ergänze die Zeitleiste. + lang: text + code: | + Frame 1 Anlauf, verwundbar + Frame 2–⟦12⟧ i-Frames + Frame ⟦13⟧–18 Weg, verwundbar + Frame 19–⟦26⟧ Erholung + Frame ⟦27⟧ wieder frei + Weg: 220 × 18 / 60 = ⟦66⟧ px + gaps: + - label: Letzter geschützter Frame + hint: Steht in `invulnerable_to`. + - label: Erster ungeschützter Frame danach + hint: Ein Frame nach dem letzten geschützten. + - label: Letzter Frame der Erholung + hint: 18 Frames Ausweichen, dann 8 Frames Erholung. + - label: Erster freier Frame + hint: Einer nach dem letzten Erholungsframe. + - label: Weg in Pixeln + hint: Pixel pro Sekunde mal Sekunden. + explanation: | + Zustände zählen ab 1. Die i-Frames enden mit Frame 12, Frame 13 ist wieder verwundbar, obwohl die Figur sich noch bewegt. Nach 18 Frames Ausweichen folgen 8 Frames Erholung, 19 bis 26, und in Frame 27 ist die Figur frei. Der Weg: 220 Pixel pro Sekunde für 18/60 Sekunden sind 66 Pixel. + + - id: geschuetzt-bedingung + type: gap + title: Ist die Figur geschützt? + prompt: | + Ergänze das Modell: `start` ist der Frame, in dem das Ausweichen beginnt (Frame 1 des Ausweichens), `frame` der Frame, in dem die Hitbox prüft. + lang: python + code: | + def geschuetzt(start, frame, von, bis): + n = ⟦frame - start + 1⟧ + return ⟦von <= n <= bis⟧ + gaps: + - label: Frame im Ausweichen + hint: Im Start-Frame selbst soll 1 herauskommen. + accept: ["frame + 1 - start", "1 + frame - start", "(frame - start) + 1"] + - label: Bedingung + hint: Beide Grenzen gehören dazu. + accept: ["n >= von and n <= bis", "von <= n and n <= bis", "bis >= n >= von", "n <= bis and n >= von", "n in range(von, bis + 1)"] + explanation: | + Ohne das `+ 1` wäre der Start-Frame Frame 0, und jedes Fenster läge einen Frame daneben. Beide Grenzen sind inklusiv, genau wie in `is_invulnerable()` der Testszene. Mit `start = 29` und `frame = 30` ergibt sich n = 2, also geschützt, wie im gemessenen Log. + resources: + - title: Python · Vergleiche verketten + url: https://docs.python.org/3/reference/expressions.html#comparisons + + - id: punish-notiz + type: bug + title: Ein Frame zu früh frei + prompt: | + Du notierst das Punish-Fenster nach dem Ausweichen. Die Rechnung und das Ergebnis passen nicht zusammen. Tippe die falsche Zeile an und korrigiere sie. + lang: text + code: | + Druck dodge in Frame 28 + Ausweichen 18 + Erholung 8 = 26 Frames + Figur frei ab Frame 53 + Dummy in Erholung bis Frame 63 + Punish-Fenster: 10 Frames + lines: [3] + fix: + - line: 3 + answers: ["Figur frei ab Frame 54", "Figur frei ab 54"] + explanation: | + Frame 28 ist Frame 1 des Ausweichens. 26 Frames belegen 28 bis 53, frei ist die Figur in Frame 54. So steht es auch im gemessenen Log: `54 Spieler DODGE_RECOVERY -> MOVE`. Von 54 bis 63 sind es 10 Frames, die letzte Zeile stimmt also. Der Fehler ist der klassische: Start-Frame plus Dauer ist der erste freie Frame, nicht der letzte gebundene. + + - id: modell-strenge-grenze + type: bug + title: Das Modell widerspricht dem Log + prompt: | + Im gemessenen Log weicht die Figur mit Druck in Frame 29 aus. Dieses Modell sagt für 29 aber `False`. Finde die Zeile und korrigiere sie. + lang: python + code: | + VON, BIS = 2, 12 + AN, AKTIV = 30, 4 + + def geschuetzt(start, frame): + n = frame - start + 1 + return VON < n <= BIS + + def ausgewichen(start): + frames = range(AN, AN + AKTIV) + return all(geschuetzt(start, f) + for f in frames) + + print(ausgewichen(29)) + lines: [6] + fix: + - line: 6 + answers: ["return VON <= n <= BIS", "return n >= VON and n <= BIS", "return VON <= n and n <= BIS", "return n in range(VON, BIS + 1)"] + explanation: | + Mit Druck in Frame 29 ist die Figur in Frame 30 im zweiten Frame ihres Ausweichens, n = 2. `VON < n` verlangt aber n > 2. `invulnerable_from = 2` heißt „ab Frame 2 einschließlich“, also `VON <= n`. Solche Grenzfehler fallen nur auf, wenn du ein Modell gegen echte Logs prüfst, am besten gegen den knappsten Fall. + + - id: frame-30-zu-spaet + type: explain + title: Warum Frame 30 zu spät war + prompt: | + Der Treffer steht in Frame 31, gedrückt wurde in Frame 30. Erkläre in wenigen Sätzen, warum dieser Druck zu spät war und warum Frame 29 gereicht hat. + lang: text + code: | + 10 Dummy WAIT -> TELEGRAPH + 30 Druck dodge + 30 Spieler MOVE -> DODGE + 30 Dummy TELEGRAPH -> ACTIVE + 31 Spieler getroffen in DODGE, Frame 1 + points: + - Die Hitbox geht in Frame 30 an, und Godot prüft die Überlappung am Ende dieses Frames. + - Gedrückt in Frame 30, ist die Figur dort in Frame 1 des Ausweichens, dem Anlauf ohne i-Frames. + - Das Treffer-Signal kommt erst am Anfang von Frame 31, deshalb steht der Treffer dort. + - Gedrückt in Frame 29, ist die Figur in Frame 30 schon in Frame 2 und geschützt. + explanation: | + Es zählt der Frame, in dem die Hitbox angeht, nicht der, in dem der Treffer im Log steht. In Frame 30 ist die Figur nach einem Druck in Frame 30 im Anlauf, also verwundbar. Godot meldet die Überlappung einen Frame später, in Frame 31; da steht die Figur noch in ihrem ersten Ausweich-Frame, deshalb „Frame 1“. Ein Frame früher gedrückt, wäre sie in Frame 30 schon im zweiten Frame gewesen, mit abgeschalteter Hurtbox. + + - id: clip-ausweichen + type: practice + title: "Clip-Analyse: was vor dem Ausweichen zu sehen ist" + prompt: | + Sieh dir den Clip [What Makes a Good Combat System?](https://www.youtube.com/watch?v=8X4fx-YncqA) von Game Maker's Toolkit an oder spiel zehn Minuten ein Actionspiel mit Ausweichen, das du besitzt. Wähle drei Ausweichmanöver und notiere zu jedem: + + - Was war vor dem Druck zu sehen oder zu hören? + - Ging die Figur durch den Angriff hindurch oder an ihm vorbei? + - Was kam danach: Punish, Abstand oder nichts? + - Was hat das Ausweichen gekostet? + + Frame-Zahlen eines fremden Spiels kennst du nur, wenn eine Quelle sie nennt. Alles andere notierst du als Schätzung. + lang: text + solution: | + Beispiel für die Form, am Trainingsgegner der Testszene: + + Ausweichen 1 (Druck in Frame 28) + Vorher: gelbe Bodenfläche ab Frame 10 + Durch oder vorbei: vorbei, 66 px weit + Danach: 10 Frames, bevor der Dummy frei ist + Kosten: 26 Frames gebunden, Frame 1 ohne Schutz + Quelle der Zahlen: gemessenes Log + + Bei einem fremden Spiel steht in der letzten Zeile + etwa „geschätzt, nicht gemessen“. + checklist: + - Zu jedem der drei Ausweichmanöver steht, was vor dem Druck zu sehen oder zu hören war. + - Du hast jedes Mal „durch“, „vorbei“ oder „unklar“ notiert. + - Zahlen, die du weder gemessen noch in einer Quelle gelesen hast, sind als Schätzung markiert. + - Mindestens eine Notiz beschreibt, was nach dem Ausweichen kam. + explanation: | + Die Übung trainiert den Blick, den du beim Abstimmen brauchst: Was hat der Spieler gesehen, welche Art von Schutz hat gewirkt, und was hat es ihm gebracht? Die Trennung von gemessen und geschätzt schützt dich davor, gefühlte Zahlen aus einem Clip später als Fakten in eine Vorgabe zu schreiben. + resources: + - title: Game Maker's Toolkit · What Makes a Good Combat System? + url: https://www.youtube.com/watch?v=8X4fx-YncqA + + - id: eigene-ausweich-fragen + type: practice + title: Die Fragen zu deinem Ausweichen + prompt: | + Für dein Spiel triffst du die Entscheidungen. Schreib eine kurze Notiz mit den Fragen, die du für dein Ausweichen beantworten musst, bevor du Werte einträgst. Noch keine Antworten, nur Fragen. Hinter jede Frage schreibst du die Größe, die sie misst: Frames, Pixel oder Zustand. + lang: text + solution: | + Beispiel für die Form, an der Testszene. Keine Vorlage + für dein Spiel, die Fragen dort findest du selbst. + + 1. Ab welchem Frame schützt das Ausweichen? + Größe: invulnerable_from (Frames) + 2. Aus welchen Zuständen darf es starten? + Größe: Zustände (MOVE, RECOVERY ab Frame …) + 3. Wie weit trägt es die Figur? + Größe: dodge_speed × dodge_frames / 60 (Pixel) + 4. Wie lange ist die Figur danach gebunden? + Größe: dodge_recovery (Frames) + 5. Was bekommt der Spieler für ein knappes Ausweichen? + Größe: Punish-Fenster (Frames) oder Ressource + checklist: + - Jede Frage nennt eine Größe, die du messen oder ablesen kannst. + - Die Notiz enthält noch keine Werte. + - Mindestens eine Frage betrifft die Kosten und mindestens eine die Belohnung. + - Mindestens eine Frage betrifft, aus welchen Zuständen das Ausweichen starten darf. + explanation: | + Wer vor dem Abstimmen die Fragen aufschreibt, ändert später gezielt eine Größe statt nach Gefühl an vielen. Die Notiz ist deine; die Testszene zeigt nur, wie solche Fragen auf Felder im Inspektor abbilden. Am PC prüfst du die Antworten in [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen) und [Tuning als Experiment](/combat/tuning). + resources: + - title: Melee AI in The Last of Us Part II (GDC 2021, Folien) + url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf diff --git a/content/combat/modules/eingabe.yaml b/content/combat/modules/eingabe.yaml new file mode 100644 index 0000000..58c87e9 --- /dev/null +++ b/content/combat/modules/eingabe.yaml @@ -0,0 +1,594 @@ +id: eingabe +title: "Eingabe: Reaktionszeit, Latenz und Puffer" +level: 1 +minutes: 100 +summary: Wie schnell Menschen reagieren, wie viel Zeit zwischen Daumen und Bildschirm vergeht und wie Spiele mit Puffer, Coyote Time und Abbruchfenstern Drücke retten, die nicht genau im richtigen Frame kommen – mit einem Python-Modell des Puffers der Testszene. +goals: + - Du nennst die einfache Reaktionszeit nach Woods et al. in Millisekunden und Frames und erklärst, warum sie im Kampf eine Untergrenze ist. + - Du beschreibst die Latenzkette vom Controller bis zum Bildschirm und wie ein Druck in Godot bei `_physics_process` ankommt. + - Du sagst für einen Druck und einen Puffer vorher, ob und in welchem Frame die Aktion startet, im Python-Modell wie in der Testszene. + - Du unterscheidest Input Buffer, Coyote Time und Abbruchfenster und nennst belegte Werte veröffentlichter Spiele. + - Du erklärst Commitment und Eingabehierarchie an Dreamscaper und am Code der Testszene. +resources: + - title: Woods et al. · Factors influencing the latency of simple reaction time (2015) + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + - title: Godot Docs · Fixing jitter, stutter and input lag + url: https://docs.godotengine.org/en/stable/tutorials/rendering/jitter_stutter.html#input-lag + - title: Infil · The Fighting Game Glossary · Buffer Window + url: https://glossary.infil.net/?t=Buffer%20Window + - title: Maddy Thorson · Celeste & Forgiveness + url: https://maddymakesgames.com/articles/celeste_and_forgiveness/index.html + - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" + url: https://www.youtube.com/watch?v=3Omb5exWpd4 +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Ein echtes Log aus der Testszene, nur die Zeilen der Figur. Sie steckt nach einem Treffer in der Erholung, und du drückst erst Ausweichen, dann Angriff: + + ```text + 27 Spieler ACTIVE -> RECOVERY + 36 Druck dodge + 38 Druck attack + 41 Spieler RECOVERY -> MOVE + 41 Spieler MOVE -> WINDUP + ``` + + Beide Drücke kamen an, und beide waren in Frame 41 jung genug für den Puffer von 6 Frames. Trotzdem startet nur der Angriff, denn die Testszene merkt sich nur den letzten Druck. Ob ein Spiel so entscheiden soll, ist eine Designfrage; dass die Testszene so entscheidet, steht im Code und im Log. Ob ein Druck zählt, hängt an drei Dingen: wie schnell ein Mensch reagiert, wie lange ein Druck braucht, bis das Spiel ihn sieht, und was das Spiel mit Drücken macht, die nicht genau im richtigen Frame kommen. Darum geht es hier, dazu um Commitment, Abbruchfenster und Stick-Eingaben. Alle Werte sind Beispiele aus veröffentlichten Spielen oder Platzhalter der Testszene. Wie groß deine Fenster werden, entscheidest du. + + ## Reaktionszeit: die Untergrenze des Menschen + + Woods und Kollegen haben 2015 die einfache Reaktionszeit von 1 469 Personen zwischen 18 und 65 Jahren gemessen ([Woods et al. 2015](https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full)). Einfach heißt: ein bekanntes Signal, eine bekannte Taste, nichts zu entscheiden. Im Mittel kamen 231 ms heraus, nach Abzug der Verzögerung der Messhardware 213 ms. Die jüngste Gruppe lag bei 217,8 ms, korrigiert bei 200 ms. In Frames: + + ```text + 213 ms × 60 / 1000 = 12,8 ≈ 13 Frames + 200 ms × 60 / 1000 = 12,0 = 12 Frames + ``` + + Auf Töne reagieren Menschen schneller als auf Licht. Jain und Kollegen zitieren Literaturwerte von etwa 190 ms für Licht und 160 ms für Ton ([Jain et al. 2015](https://pmc.ncbi.nlm.nih.gov/articles/PMC4456887)). Ein Ton zum Telegraph kann also früher eine Reaktion auslösen als das Bild allein. Das ist eine Folgerung aus der Studie, keine Messung an einem Spiel. + + Im Kampf ist die einfache Reaktionszeit eine Untergrenze. Der Spieler muss erst erkennen, welcher Angriff kommt, und entscheiden, ob er ausweicht oder angreift, oft während er schon etwas anderes tut. Das Fighting Game Glossary von Infil, eine Community-Quelle, hält fest: Auch sehr gute Spieler werden oft von Angriffen mit 20 und mehr Frames Startup getroffen, weil sie zu viel gleichzeitig im Kopf haben ([Reaction](https://glossary.infil.net/?t=Reaction)). Rechne die Testszene durch. Von 20 Frames Telegraph bleiben nach 13 Frames Reaktion 7 Frames für Erkennen und Entscheiden. Davon zieht die Technik noch etwas ab. + + ## Die Latenzkette: vom Daumen bis zum Bild + + NVIDIA nennt die Zeit von der Eingabe bis zur geänderten Anzeige Systemlatenz und zerlegt sie in Stationen ([Introducing NVIDIA Reflex](https://www.nvidia.com/en-gb/geforce/news/reflex-low-latency-platform/), ein Herstellerartikel): + + ```text + Daumen drückt + │ Peripherie: Controller sendet + ▼ + Spiel liest den Druck + │ Spiel: Simulation, neues Bild + ▼ + Grafikkarte rendert + │ Render + ▼ + Bildschirm zeigt das Bild + Display + ``` + + Laut NVIDIA spielen die meisten mit 50 bis 100 ms Systemlatenz, also 3 bis 6 Frames. Mick West kam 2008 für typische 60-fps-Spiele auf 4/60 s, Flachbildschirme legten 2 Frames drauf (siehe [Spielgefühl](/combat/spielgefuehl)). Ein Forschungsteam von NVIDIA fand bei Zielaufgaben in der Ego-Perspektive, dass geringere Latenz die Zeit bis zum Ziel klar verkürzt. Eine höhere Bildwiederholrate brachte bei gleicher Latenz wenig ([Spjut et al. 2019](https://research.nvidia.com/labs/amri/publication/spjut2019latency)). + + In Godot sieht die Station „Spiel“ so aus. Das Betriebssystem liefert Eingabe-Events, Godot gibt sie mindestens einmal pro gezeichnetem Bild weiter. Mit `use_accumulated_input`, standardmäßig an, fasst Godot ähnliche Events eines Bildes zusammen ([Input.use_accumulated_input](https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-property-use-accumulated-input)). `Input.is_action_just_pressed()` in `_physics_process` ist dann im nächsten Physik-Frame wahr. Deshalb schreibt die Testszene `Druck` in den Frame, in dem die Figur den Druck sieht, nicht in den Moment, in dem dein Daumen unten war. + + Die Godot-Doku listet Maßnahmen gegen Input Lag ([Fixing jitter, stutter and input lag](https://docs.godotengine.org/en/stable/tutorials/rendering/jitter_stutter.html#input-lag)): + + - V-Sync abschalten oder als Option anbieten; ohne V-Sync reißt das Bild (Tearing). + - *Swapchain Image Count* auf 2 (Forward+ und Mobile), wenn das Spiel die Bildwiederholrate sicher erreicht. + - Mehr Physik-Ticks pro Sekunde; als Startpunkte nennt die Doku 120, 180 und 240. + - `Input.set_use_accumulated_input(false)`, das kostet etwas Rechenzeit. + - Variable Bildwiederholrate (G-Sync, FreeSync) mit V-Sync und einer FPS-Grenze knapp darunter, etwa 141 bei 144 Hz. + + Physik-Interpolation erhöht laut Doku den Input Lag für alles, was am Physik-Tick hängt. + + > [!WARNING] Mehr Ticks, andere Frames + > Die Frame-Daten dieses Kurses gelten für 60 Physik-Ticks pro Sekunde. Stellst du auf 120, dauert ein Frame 8,3 ms, und ein Wind-up von 8 Frames ist nur noch 67 ms lang. Wer die Tickrate ändert, rechnet jede Frame-Zahl neu um. + + ## Input Buffering: ein Druck, der wartet + + Die Erholung eines Angriffs endet in einem bestimmten Frame. Einen Druck genau dorthin zu legen heißt, ein Fenster von 16,7 ms zu treffen, bei einer Reaktionszeit von über 200 ms. Ohne Hilfe verfällt jeder Druck, der einen Frame zu früh kommt, und der Spieler erlebt, dass das Spiel seine Eingabe verschluckt. + + Ein **Input Buffer** (Eingabepuffer) merkt sich den Druck. Infil beschreibt das Buffer Window als Zeitraum, in dem das Spiel die Eingabe schon annimmt und sie im ersten möglichen Frame ausführt ([Buffer Window](https://glossary.infil.net/?t=Buffer%20Window)). Kampfspiele haben solche Puffer an vielen Stellen, damit sich Spieler darauf verlassen können, dass ihre Aktion kommt. + + Die Testszene aus [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) prüft so: + + ```gdscript + func _consume(action: StringName) -> bool: + if buffered_action == action and buffer_age >= 0 and buffer_age <= input_buffer: + buffer_age = -1 + return true + return false + ``` + + `_read_buttons()` setzt bei jedem neuen Druck `buffered_action` und `buffer_age = 0`. Am Ende jedes Frames altert der Druck um eins, im Hitstop nicht. Ist die Figur frei, fragt sie mit `_consume()`, ob ein passender Druck da ist. Zwei echte Läufe mit dem Platzhalter `input_buffer = 6`, als Ausschnitt mit den Zeilen der Figur; die Erholung endet in Frame 41: + + ```text + 27 Spieler ACTIVE -> RECOVERY + 32 Druck attack + 41 Spieler RECOVERY -> MOVE + ``` + + ```text + 27 Spieler ACTIVE -> RECOVERY + 37 Druck attack + 41 Spieler RECOVERY -> MOVE + 41 Spieler MOVE -> WINDUP + ``` + + Der Druck aus Frame 32 ist in Frame 41 neun Frames alt und verfällt. Der aus Frame 37 ist vier Frames alt, und der zweite Angriff startet im ersten freien Frame. + + Dieselbe Regel als Python-Modell. `startet()` bekommt die Frames, in denen die Figur einen Druck sieht, und den ersten freien Frame. Zurück kommt der Frame, in dem die Aktion startet, oder `None`: + + ```python run + def startet(drucke, frei, puffer=6): + alter = -1 # Puffer leer + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = 0 # _read_buttons + if f == frei: # _consume + if 0 <= alter <= puffer: + return f + return None # verfallen + alter += 1 # Ende des Frames + + print(startet([37], 41)) + print(startet([32], 41)) + print(startet([36, 38], 41)) + ``` + + Das Modell kennt keinen Hitstop und keine Drücke nach dem freien Frame. Für die Frage „Zählt dieser Druck?“ reicht es. Ein neuer Druck setzt das Alter auf 0, auch wenn schon einer gemerkt war. Es zählt immer der jüngste. + + > [!MERKE] + > Ein Druck zählt, wenn er im ersten möglichen Frame höchstens `input_buffer` Frames alt ist. Puffer 0 heißt: nur im selben Frame. + + Eine Puffergröße mit Zahl nennt keine Quelle dieses Kurses. Ian Cofino nennt das Buffering in Dreamscaper großzügig und achtet darauf, dass Hämmern auf die Taste nicht „over registered“ wird, also nicht mehr Aktionen auslöst, als der Spieler meint (ab etwa 34:18, [GDC 2022](https://www.youtube.com/watch?v=3Omb5exWpd4)). Die Testszene merkt sich nur einen Druck, und `_consume()` leert ihn. Fünfmal Hämmern in der Erholung ergibt dort höchstens einen Angriff. Ein großer Puffer hat eine Kehrseite: Er führt auch einen Druck aus, den der Spieler inzwischen bereut. Wie du „verschluckt“ gegen „zu spät noch ausgeführt“ abwägst, entscheidest du. + + ## Nachsicht: Coyote Time und andere Fenster + + Maddy Thorson beschreibt zehn Tricks, die Celeste nachsichtig machen, und fasst zusammen: „All are centered around widening timing or positioning windows“ ([Celeste & Forgiveness](https://maddymakesgames.com/articles/celeste_and_forgiveness/index.html)). Zwei davon betreffen das Timing von Eingaben: + + - **Coyote Time.** Du kannst noch kurz springen, nachdem du die Kante verlassen hast. + - **Jump Buffering.** Drückst du kurz vor der Landung, springst du genau im Frame der Landung. + + Thorson nennt keine Zahlen. Im veröffentlichten Quellcode der Spielfigur steht `JumpGraceTime = 0.1f` ([Player.cs, Zeile 48](https://github.com/NoelFB/Celeste/blob/master/Source/Player/Player.cs)): 0,1 s Coyote Time, bei 60 Frames pro Sekunde 6 Frames. Sébastien Benard nennt für Dead Cells „two or three frames to jump anyway“ (14:42). Solche Hilfen hätten „much much more difficult combat“ möglich gemacht (22:10, [Dead Cells: What the F\*n!?](https://www.youtube.com/watch?v=OfSpBoA6TWw)). + + | Spiel | Fenster | Wert | + | --- | --- | --- | + | Celeste | Coyote Time | 0,1 s = 6 Frames | + | Dead Cells | Coyote Time | 2–3 Frames | + | Dreamscaper | Puffer | „großzügig“, ohne Zahl | + | Testszene | Puffer | 6 Frames, Platzhalter | + + Puffer und Coyote Time sind Spiegelbilder. Der Puffer lässt einen zu frühen Druck gelten, Coyote Time einen zu späten. Coyote Time und Jump Buffering stammen aus Plattformern, Eingabepuffer kennen Kampfspiele seit Langem. In der Draufsicht gibt es keine Kante, aber dieselbe Frage: Gilt ein Druck, der ein paar Frames neben dem Moment liegt, in dem er möglich war? Ob dein Spiel solche Fenster braucht und wie groß, sind Designentscheidungen. Die Werte oben sind Beispiele, keine Vorgabe. + + ## Commitment, Eingabehierarchie und Abbruchfenster + + **Commitment** heißt, wie fest der Spieler nach dem Drücken an seine Aktion gebunden ist. In der Testszene ist er es ganz. Während Wind-up, aktiver Phase und Erholung verbraucht die Figur keinen Druck. Nur ein Angriff darf die Erholung abbrechen, und erst ab dem Erholungs-Frame `cancel_from`; der Platzhalter 0 heißt nie. Ein Ausweichen wartet, bis die Figur frei ist. + + Ein **Abbruchfenster** (Cancel) sind die Frames, in denen eine Aktion eine andere abbrechen darf. Sakurai legt die Cancel-Frames in den Follow-through, das Ausschwingen nach dem Treffer (2:06). Smash hatte bis Melee keine, und die Spiele fühlten sich schwer an (2:26, [Breaking Down Attack Animations](https://www.youtube.com/watch?v=LewXWM7HDd8)). Hades II hat das Ausweichen aus der Erholung in einem Patch ausdrücklich zuverlässiger gemacht (siehe [Ausweichen, Risiko und Belohnung](/combat/ausweichen)). + + Welche Aktion welche abbrechen darf, legt eine **Eingabehierarchie** fest. Cofino beschreibt sie für Dreamscaper: Ausweichen und Blocken unterbrechen fast alles (22:57). Das Ausweichen macht sofort unverwundbar (8:18). Spielerangriffe sind schneller als Gegnerangriffe, damit der Spieler den Startup eines Gegners unterbrechen kann (Fragerunde, etwa 54:00). Man kann das als Antwort auf die Reaktionszeit lesen: Der Spieler bekommt Aktionen, die schneller wirken, als ein Gegner zuschlägt. Die Testszene stellt Ausweichen dagegen unter den Angriff. Beides ist eine Entscheidung, keine Regel. + + ## Stick-Eingaben: Flick Input bei 360°-Bewegung + + Mit einem Stick ist eine Eingabe mehr als eine Taste. `Input.get_vector()` liefert pro Frame einen Richtungsvektor bis Länge 1, die Deadzone neuer Aktionen ist 0,2 ([Input.get_vector](https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-get-vector)). Sakurai beschreibt in „Flick Input“, wie ein Spiel auch lesen kann, wie schnell der Stick geneigt wird (etwa 0:35–1:20, [Clip](https://www.youtube.com/watch?v=ChNceKFm2kI)). Ein schnelles Schnippen ist ein Flick, langsames Neigen ist Laufen. Das ersetzt Doppeltippen, kann aber falsch auslösen. In Kid Icarus: Uprising liegen Dash und Ausweichen auf dem Flick (1:28). + + Für 360°-Bewegung heißt das: Ein Flick ist eine Änderung des Vektors zwischen zwei Frames, nicht sein Wert. Ein schneller Richtungswechsel kann eine ähnliche Änderung erzeugen. Ob dein Ausweichen auf einer Taste, einem Flick oder beidem liegt, entscheidest du. Messen kannst du beides in Frames. + + > [!PRAXIS] + > Studios stimmen Fenster auf die Latenz der Plattform ab. Dreamscaper hat auf der Switch, mit etwa 8 Frames Eingabelatenz, ein breiteres Parry-Fenster (21:01). Ein Fenster in Frames bekommt erst zusammen mit der Latenz eine Bedeutung. + + > [!INTERVIEW] + > „Was ist Input Buffering, und was passiert, wenn der Puffer zu groß ist?“ Gute Antwort: Ein Puffer merkt sich einen Druck, der zu früh kommt, und führt die Aktion im ersten möglichen Frame aus. So gehen Eingaben kurz vor dem Ende einer Aktion nicht verloren. Ist er zu groß, führt das Spiel Drücke aus, die der Spieler längst aufgegeben hat. Sammelt er mehrere Drücke, macht Hämmern mehrere Aktionen daraus. Dann ein Satz zur Messung: Logg das Alter jedes Drucks beim Verbrauchen, dann siehst du, wie alt die Drücke sind, die wirklich zählen. + + ## Häufige Fehler + + - **Reaktionszeit als Spielzeit.** 213 ms gelten für ein bekanntes Signal und eine Taste. Im Kampf kommen Erkennen und Entscheiden dazu. + - **Telegraph ohne Latenz gerechnet.** Der Spieler sieht den Telegraph erst nach Render und Display, und sein Druck kommt erst im nächsten Physik-Frame im Spiel an. + - **Tickrate geändert, Frame-Daten behalten.** Bei 120 Ticks pro Sekunde ist ein Frame 8,3 ms lang. Rechne jede Zahl neu um. + - **`<` statt `<=` im Puffer.** Puffer 0 wirkt dann gar nicht, jeder andere ist einen Frame kürzer als eingestellt. + - **Puffer altert im Hitstop.** Steht das Altern vor der Hitstop-Prüfung, verfällt ein Druck, während das Bild stillsteht. + - **Hämmern nicht getestet.** Ob dein Puffer einen oder mehrere Drücke merkt, siehst du erst, wenn du mehrmals drückst und die Aktionen im Log zählst. + + ## Zusammenfassung + + - Einfache Reaktionszeit nach Woods et al.: 231 ms, korrigiert 213 ms, etwa 13 Frames. Auf Töne reagieren Menschen schneller als auf Licht. + - Im Kampf ist das eine Untergrenze, weil Erkennen und Entscheiden dazukommen. + - Die Latenzkette läuft über Peripherie, Spiel, Render und Display. In Godot sieht `_physics_process` einen Druck im nächsten Physik-Frame. + - Ein Input Buffer lässt einen zu frühen Druck gelten, in der Testszene, wenn er höchstens `input_buffer` Frames alt ist. Es zählt der jüngste Druck. + - Coyote Time lässt einen zu späten Druck gelten: Celeste 0,1 s, Dead Cells 2–3 Frames. + - Commitment, Eingabehierarchie und Abbruchfenster legen fest, was eine laufende Aktion beenden darf. In Dreamscaper unterbrechen Ausweichen und Blocken fast alles. + - Flick Input liest die Geschwindigkeit des Sticks, nicht nur die Richtung. + - Puffer, Coyote Time und Abbruchfenster sind Designentscheidungen. Veröffentlichte Werte sind Beispiele. +exercises: + - id: reaktionszeit-untergrenze + type: choice + title: Reichen 13 Frames? + prompt: | + 213 ms sind knapp 13 Frames. Jemand überlegt: „Dann reicht ein Telegraph von 13 Frames, um auszuweichen.“ Welche Einwände stimmen? + options: + - text: Woods et al. messen mit bekanntem Signal und bekannter Taste. Im Kampf muss der Spieler erst erkennen, welcher Angriff kommt, und entscheiden. + correct: true + why: Einfache Reaktionszeit ist der günstigste Fall. Erkennen und Entscheiden kosten zusätzlich Zeit. + - text: Der Spieler sieht den Telegraph erst nach Render und Display, und sein Druck kommt erst im nächsten Physik-Frame im Spiel an. + correct: true + why: Die Latenzkette frisst Frames an beiden Enden, bevor die Reaktion überhaupt beginnt und nachdem sie fertig ist. + - text: 213 ms ist der langsamste gemessene Wert. Fast alle Menschen reagieren schneller. + correct: false + why: 213 ms ist ein Mittelwert über 1 469 Personen, kein Höchstwert. Viele reagieren langsamer. + - text: Die Reaktionszeit spielt keine Rolle, weil der Eingabepuffer den Druck ohnehin merkt. + correct: false + why: Der Puffer lässt einen zu frühen Druck gelten. Um auf einen Telegraph zu reagieren, muss der Druck aber nach dem Erkennen kommen. Schneller macht der Puffer niemanden. + explanation: | + 13 Frames sind die reine Reaktion im Labor. Im Spiel kommen Erkennen und Entscheiden dazu, und die Technik kostet Frames: Das Bild erscheint später, als das Spiel den Telegraph startet, und der Druck kommt später an, als der Daumen drückt. Infil hält fest, dass auch sehr gute Spieler oft von Angriffen mit 20 und mehr Frames Startup getroffen werden. Wie lang dein Telegraph wird, entscheidest du. Die 13 Frames sagen dir nur, wo es sicher zu knapp wird. + resources: + - title: Woods et al. · Factors influencing the latency of simple reaction time (2015) + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + - title: Infil · The Fighting Game Glossary · Reaction + url: https://glossary.infil.net/?t=Reaction + + - id: latenzkette-ordnen + type: order + title: Vom Daumen bis zum Bild + prompt: | + Bring die Stationen eines Ausweich-Drucks in Godot in die richtige Reihenfolge. + lang: text + lines: | + Der Daumen drückt, der Controller sendet den Druck + Godot gibt das Event mit dem nächsten Bild weiter + is_action_just_pressed() wird wahr + Die Figur wechselt von MOVE nach DODGE + Die Grafikkarte rendert das Bild mit der Ausweichpose + Der Bildschirm zeigt das neue Bild + explanation: | + Die Reihenfolge folgt NVIDIAs Stationen: Peripherie (Zeile 1), Spiel (Zeilen 2 bis 4), Render (Zeile 5), Display (Zeile 6). Godot arbeitet Eingabe-Events mindestens einmal pro gezeichnetem Bild ab, und `is_action_just_pressed()` ist im nächsten Physik-Frame wahr. Erst danach kann die Simulation den Zustand wechseln. Jede Station kostet Zeit, und der Spieler sieht erst das Ergebnis der letzten. + resources: + - title: Introducing NVIDIA Reflex (Latenzkette) + url: https://www.nvidia.com/en-gb/geforce/news/reflex-low-latency-platform/ + - title: Godot Docs · Input.use_accumulated_input + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-property-use-accumulated-input + + - id: puffer-oder-coyote + type: choice + title: Zu früh, zu spät + prompt: | + Welche Aussagen über Puffer und Coyote Time stimmen? + options: + - text: Ein Puffer lässt einen Druck gelten, der zu früh kam, und führt die Aktion im ersten möglichen Frame aus. + correct: true + why: So beschreibt Infil das Buffer Window, und so arbeitet die Testszene. + - text: Coyote Time lässt einen Sprung gelten, der kurz nach dem Verlassen der Kante kommt. + correct: true + why: Das ist Thorsons Beschreibung aus Celeste. Das Fenster liegt nach dem Moment, in dem der Sprung möglich war. + - text: Thorson nennt im Artikel „Celeste & Forgiveness“ 6 Frames Coyote Time. + correct: false + why: Der Artikel nennt keine Zahlen. Die 0,1 s stehen im Quellcode als `JumpGraceTime`, 6 Frames ist die Umrechnung bei 60 Frames pro Sekunde. + - text: Puffer und Coyote Time sind dasselbe Fenster, nur unter zwei Namen. + correct: false + why: Sie liegen auf verschiedenen Seiten des richtigen Moments. Der Puffer verzeiht Frühe, Coyote Time Verspätung. + explanation: | + Beide Fenster verzeihen, dass ein Mensch keinen einzelnen Frame trifft, aber in verschiedene Richtungen. Der Puffer merkt sich einen frühen Druck und wartet, Coyote Time wertet einen späten Druck so, als wäre er noch rechtzeitig gekommen. Thorson fasst ihre Tricks so zusammen: Sie verbreitern Fenster für Timing oder Position. Zahlen findest du nur im Quellcode, nicht im Artikel. + resources: + - title: Maddy Thorson · Celeste & Forgiveness + url: https://maddymakesgames.com/articles/celeste_and_forgiveness/index.html + - title: Celeste · Player.cs, JumpGraceTime + url: https://github.com/NoelFB/Celeste/blob/master/Source/Player/Player.cs + + - id: reaktion-in-frames + type: output + title: Reaktionszeit in Frames + prompt: | + Woods et al. messen korrigiert 213 ms einfache Reaktionszeit. Wie viele Frames sind das bei 60 Frames pro Sekunde, auf ganze Frames gerundet? Antworte mit der Zahl. + lang: text + code: | + Frames = ms × 60 / 1000 + 213 ms = ? Frames + expected: | + 13 + accept: + - 13 Frames + - ca. 13 + - etwa 13 + explanation: | + 213 × 60 / 1000 = 12,78, gerundet 13 Frames. Die jüngste Gruppe der Studie lag korrigiert bei 200 ms, also 12 Frames. Merk dir die Größenordnung: Ein Mensch braucht im besten Fall etwa ein Dutzend Frames, um auf ein erwartetes Signal zu drücken. Ein Puffer von wenigen Frames oder ein Fenster von einem Frame liegt weit darunter. + resources: + - title: Woods et al. · Factors influencing the latency of simple reaction time (2015) + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + + - id: puffer-grenzfall + type: output + title: Genau an der Grenze + prompt: | + Das Puffer-Modell aus der Lektion, Puffer 6 wie der Platzhalter der Testszene. Die Figur ist ab Frame 41 frei. Was gibt das Programm aus? + lang: python + code: | + def startet(drucke, frei, puffer=6): + alter = -1 + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = 0 + if f == frei: + if 0 <= alter <= puffer: + return f + return None + alter += 1 + + print(startet([35], 41), startet([34], 41)) + expected: | + 41 None + explanation: | + Der Druck aus Frame 35 ist in Frame 41 genau 6 Frames alt. Die Grenze ist inklusiv (`<=`), also startet die Aktion in Frame 41. Der Druck aus Frame 34 ist dort 7 Frames alt und verfällt. Genauso rechnet die Testszene mit `buffer_age <= input_buffer`, solange kein Hitstop dazwischenliegt. + resources: + - title: Infil · The Fighting Game Glossary · Buffer Window + url: https://glossary.infil.net/?t=Buffer%20Window + + - id: puffer-null + type: output + title: Puffer 0 und Puffer 1 + prompt: | + Derselbe Druck in Frame 40, die Figur ist ab Frame 41 frei. Einmal mit Puffer 0, einmal mit Puffer 1. Was gibt das Programm aus? + lang: python + code: | + def startet(drucke, frei, puffer=6): + alter = -1 + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = 0 + if f == frei: + if 0 <= alter <= puffer: + return f + return None + alter += 1 + + a = startet([40], 41, puffer=0) + b = startet([40], 41, puffer=1) + print(a, b) + expected: | + None 41 + explanation: | + In Frame 41 ist der Druck einen Frame alt. Mit Puffer 0 zählt nur ein Druck im selben Frame, also verfällt er. Mit Puffer 1 darf er einen Frame alt sein, und die Aktion startet in 41. Ein Spieler, der einen Frame zu früh drückt, erlebt mit Puffer 0 eine verschluckte Eingabe. Wie viele Frames dein Puffer bekommt, ist eine Designentscheidung. + resources: + - title: Infil · The Fighting Game Glossary · Buffer Window + url: https://glossary.infil.net/?t=Buffer%20Window + + - id: puffer-hammern + type: output + title: Hämmern auf die Taste + prompt: | + Du hämmerst in der Erholung auf die Angriffstaste. Die Figur sieht die Drücke in den angegebenen Frames und ist ab Frame 41 frei. Was gibt das Programm aus? + lang: python + code: | + def startet(drucke, frei, puffer=6): + alter = -1 + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = 0 + if f == frei: + if 0 <= alter <= puffer: + return f + return None + alter += 1 + + a = startet([30, 33, 36], 41) + b = startet([30, 33], 41) + print(a, b) + expected: | + 41 None + hints: + - Jeder neue Druck setzt `alter` auf 0. + explanation: | + Jeder Druck überschreibt den vorigen, es zählt der jüngste. Im ersten Aufruf ist das der aus Frame 36, in Frame 41 fünf Frames alt: Die Aktion startet. Im zweiten ist der jüngste Druck aus Frame 33, in 41 acht Frames alt, und er verfällt, obwohl du zweimal gedrückt hast. Hämmern hilft also nur, wenn der letzte Druck spät genug kommt. Dafür löst es nie zwei Aktionen aus, wie Cofino es für Dreamscaper verlangt. + resources: + - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + + - id: puffer-modell + type: gap + title: Das Modell ergänzen + prompt: | + Ergänze das Puffer-Modell. Ein neuer Druck ist 0 Frames alt. Im ersten freien Frame zählt er, wenn er höchstens `puffer` Frames alt ist. + lang: python + code: | + def startet(drucke, frei, puffer=6): + alter = -1 + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = ⟦0⟧ + if f == frei: + if ⟦0 <= alter <= puffer⟧: + return f + return None + alter += 1 + gaps: + - label: Alter eines neuen Drucks + hint: So alt ist ein Druck in dem Frame, in dem die Figur ihn sieht. + - label: Bedingung im ersten freien Frame + hint: Ein Druck ist da (nicht −1) und nicht älter als der Puffer. + accept: + - alter >= 0 and alter <= puffer + - 0 <= alter and alter <= puffer + - alter <= puffer and alter >= 0 + - alter != -1 and alter <= puffer + - alter > -1 and alter <= puffer + - puffer >= alter >= 0 + - alter in range(puffer + 1) + - alter <= puffer and 0 <= alter + - -1 < alter <= puffer + explanation: | + `alter = 0` im Frame, in dem die Figur den Druck sieht, ist dasselbe wie `buffer_age = 0` in `_read_buttons()`. Die Bedingung prüft zwei Dinge: Es gibt einen Druck (`alter` ist nicht −1), und er ist höchstens `puffer` Frames alt. Mit `<=` gilt Puffer 0 als „nur im selben Frame“. In diesem Modell ist `alter` ab dem ersten Durchlauf nie −1, die Testszene braucht den Test aber, weil ihr Puffer meistens leer ist. + resources: + - title: Godot Docs · Input.is_action_just_pressed + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-is-action-just-pressed + + - id: puffer-zu-knapp + type: bug + title: Puffer 0 tut gar nichts + prompt: | + `startet([41], 41, puffer=0)` gibt `None`, obwohl der Druck im freien Frame selbst kommt. Und mit Puffer 6 verfällt ein Druck, der genau 6 Frames alt ist. Finde die fehlerhafte Zeile und korrigiere sie. + lang: python + code: | + def startet(drucke, frei, puffer=6): + alter = -1 + for f in range(drucke[0], frei + 1): + if f in drucke: + alter = 0 + if f == frei: + if 0 <= alter < puffer: + return f + return None + alter += 1 + lines: [7] + fix: + - line: 7 + answers: + - "if 0 <= alter <= puffer:" + - "if alter >= 0 and alter <= puffer:" + - "if 0 <= alter and alter <= puffer:" + - "if alter <= puffer and alter >= 0:" + - "if alter != -1 and alter <= puffer:" + - "if alter > -1 and alter <= puffer:" + - "if puffer >= alter >= 0:" + - "if alter in range(puffer + 1):" + explanation: | + Mit `<` ist die Obergrenze exklusiv. Bei Puffer 0 gilt dann `0 < 0`, und kein Druck zählt mehr, auch nicht im selben Frame. Bei jedem anderen Puffer ist das Fenster einen Frame kürzer als eingestellt. Solche Fehler fallen beim Spielen kaum auf, weil sie nur einen Frame kosten. Im Log siehst du sie sofort, wenn du das Alter eines verfallenen Drucks mitschreibst. + resources: + - title: Infil · The Fighting Game Glossary · Buffer Window + url: https://glossary.infil.net/?t=Buffer%20Window + + - id: belegte-werte + type: bug + title: So lang wie ein Mensch + prompt: | + Du sammelst belegte Werte für deine Notizen. Ein Freund liest mit und stutzt: Laut einer Zeile wäre die Eingabelatenz einer Konsole so lang wie die Reaktionszeit eines Menschen. Finde die Zeile und korrigiere sie im Format der anderen. + lang: text + code: | + Belegte Werte, 60 Frames pro Sekunde + Celeste Coyote 0,1 s = 6 Frames + Dead Cells Coyote 2–3 Frames + Dreamscaper Switch 130 ms = 13 Frames + Mick West PS3-Menü 3/60 s = 50 ms + lines: [4] + fix: + - line: 4 + answers: + - Dreamscaper Switch 130 ms = 8 Frames + - Dreamscaper Switch 130 ms ≈ 8 Frames + - Dreamscaper Switch 130 ms = 7,8 Frames + - Dreamscaper Switch 130 ms = 7.8 Frames + - Dreamscaper Switch 130 ms = etwa 8 Frames + explanation: | + 130 × 60 / 1000 = 7,8, also etwa 8 Frames. Cofino sagt selbst „like eight frames at 60fps“. Die 13 Frames gehören zu den 213 ms Reaktionszeit, die Zeile hat die beiden Werte vermischt. Die übrigen Zeilen stimmen: 0,1 s sind 6 Frames, 3/60 s sind 50 ms. + resources: + - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + + - id: hierarchie-testszene + type: explain + title: Wer darf wen abbrechen? + prompt: | + Dieser Ausschnitt aus `player.gd` entscheidet, wann ein gepufferter Druck eine Aktion startet. Erkläre in wenigen Sätzen, welches Commitment und welche Eingabehierarchie er festlegt, und vergleiche mit Dreamscaper. + lang: gdscript + code: | + if state == State.MOVE: + if _consume(&"attack"): + _enter(State.WINDUP) + elif _consume(&"dodge"): + _enter(State.DODGE) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + points: + - In `MOVE` startet ein gepufferter Angriff oder ein gepuffertes Ausweichen; weil der Puffer nur den jüngsten Druck hält, kommt nie beides. + - Während Wind-up, aktiver Phase und Ausweichen verbraucht der Code keinen Druck, der Spieler ist ganz an seine Aktion gebunden. + - In der Erholung darf nur ein Angriff abbrechen, und erst ab dem Erholungs-Frame `cancel_from`; mit dem Platzhalter 0 nie. + - Ein Ausweichen in der Erholung wartet auf den ersten freien Frame und zählt nur, wenn der Puffer bis dahin reicht. + - Dreamscaper stellt Ausweichen und Blocken über fast alles; welche Hierarchie dein Spiel bekommt, entscheidest du. + explanation: | + Die Testszene hat ein hohes Commitment. Ist eine Aktion gestartet, läuft sie zu Ende. Die einzige Ausnahme ist ein Angriff aus der Erholung heraus, ab `cancel_from`, und der Platzhalter schaltet auch das ab. Ein Ausweichen kann einen Angriff nie unterbrechen, es wartet im Puffer. Dreamscaper entscheidet umgekehrt: Ausweichen und Blocken brechen fast alles ab, das Ausweichen macht sofort unverwundbar. Die Testszene ist damit nicht falsch. Sie zeigt einen Platzhalter, an dem du die andere Variante ausprobieren kannst. + resources: + - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 + + - id: clip-flick-input + type: practice + title: "Clip-Analyse: Flick Input" + prompt: | + Sieh dir Sakurais Clip „Flick Input“ an (2:27). Notiere mit Zeitangaben, woran ein Spiel einen Flick vom Laufen unterscheidet, wofür Kid Icarus: Uprising ihn nutzt und welches Risiko Sakurai nennt. Schreib dann eine Situation aus einem Top-down-Spiel mit 360°-Bewegung auf, in der ein Flick ungewollt auslösen könnte, als Frage, nicht als Entscheidung. + lang: text + solution: | + Beispiel für die Form, nicht für + deine Antworten: + + Clip: Flick Input (Sakurai), 2:27 + etwa 0:35–1:20 Das Spiel liest, wie + schnell der Stick geneigt + wird. Schnell = Flick, + langsam = Laufen. Ersetzt + Doppeltippen, kann falsch + auslösen. + 1:28 Kid Icarus: Uprising, + Flick = Dash, Ausweichen. + + Frage für später: Wenn ich im Kampf den + Stick schnell von links nach rechts + reiße, um mich umzudrehen – liest das + Spiel das als Flick? + checklist: + - Du hast notiert, woran das Spiel einen Flick vom Laufen unterscheidet. + - Du hast notiert, welche Aktionen Kid Icarus auf den Flick legt. + - Du nennst das Risiko, das Sakurai beschreibt, in eigenen Worten. + - Deine Situation stammt aus einem Spiel in der Draufsicht mit analoger Bewegung. + - Du hast sie als offene Frage notiert und nicht entschieden, ob dein Spiel Flicks nutzt. + explanation: | + Ein Flick ist eine Eingabe aus der Geschwindigkeit des Sticks, nicht aus seiner Richtung. In einem Spiel mit 360°-Bewegung bewegt sich der Stick ständig, deshalb ist die Frage, wann eine schnelle Änderung gemeint ist und wann nicht. Diese Frage beantwortest du erst, wenn du es ausprobieren kannst. In der Clip-Analyse sammelst du nur, was Sakurai belegt, und was du daraus fragen willst. + resources: + - title: Masahiro Sakurai · Flick Input + url: https://www.youtube.com/watch?v=ChNceKFm2kI + - title: Godot Docs · Input.get_vector + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-get-vector + + - id: puffer-selbst-testen + type: practice + title: Puffert dein Lieblingsspiel? + prompt: | + Nimm ein Actionspiel, das du selbst spielst, und einen Angriff mit spürbarer Erholung. Schreib vorher auf, was du erwartest. Dann drück den nächsten Angriff zu verschiedenen Zeitpunkten während der Erholung: früh, in der Mitte, kurz vor Ende. Prüf außerdem, ob Hämmern mehr Angriffe auslöst und ob ein Ausweichen den Angriff abbricht. + lang: text + solution: | + Beispiel für die Form, ausgefüllt mit + echten Logs der Testszene statt eines + Spiels: + + Spiel: Testszene, input_buffer = 6 + (Platzhalter) + Erwartung: Ein früher Druck verfällt, ein + später startet den Angriff sofort. + Erholung endet in Frame 41. + + Druck Ergebnis + 32 verfällt, in 41 neun Frames alt + 37 startet in 41, vier Frames alt + + Hämmern: laut Code höchstens ein Angriff, + es zählt der jüngste Druck. + Ausweichen in der Erholung: bricht nicht ab, + startet frühestens in 41 (laut Code). + Abweichung von der Erwartung: keine. + Eindruck: ‹deine eigenen Worte› + checklist: + - Deine Erwartung stand auf dem Zettel, bevor du getestet hast. + - Du hast mindestens drei Zeitpunkte für den zweiten Druck probiert, jeden mehrmals. + - Für jeden Zeitpunkt notierst du, ob der zweite Angriff kam und ob er sofort am Ende des ersten kam. + - Du hast geprüft, ob Hämmern mehr Angriffe auslöst, als du willst. + - Du hast geprüft, ob und ab wann ein Ausweichen den Angriff abbricht. + - Dein Eindruck steht als eigener Satz da; es gibt dafür keine richtige oder falsche Antwort, und du legst keinen Wert für dein Spiel fest. + explanation: | + Ohne Log siehst du nur das Ergebnis, nicht das Alter des Drucks. Mit drei Zeitpunkten grenzt du trotzdem ein, wie groß das Fenster ungefähr ist: Kommt ein früher Druck noch durch, ist der Puffer groß, kommt nur ein später durch, ist er klein. Das Beispiel nutzt die Testszene, weil dort die echten Frames im Log stehen; bei einem fremden Spiel bleibt es bei „früh, Mitte, spät“. Was du davon für dein Spiel übernimmst, entscheidest du später in der Testszene. + resources: + - title: Infil · The Fighting Game Glossary · Buffer Window + url: https://glossary.infil.net/?t=Buffer%20Window + - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" + url: https://www.youtube.com/watch?v=3Omb5exWpd4 diff --git a/content/combat/modules/frust-diagnose.yaml b/content/combat/modules/frust-diagnose.yaml new file mode 100644 index 0000000..3486c48 --- /dev/null +++ b/content/combat/modules/frust-diagnose.yaml @@ -0,0 +1,493 @@ +id: frust-diagnose +title: Schwer, unfair oder kaputt? Treffer nachvollziehen +level: 2 +minutes: 100 +summary: Fühlt sich ein Treffer falsch an, hat das eine von drei Ursachen – der Kampf ist schwer, der Angriff war nicht lesbar, oder der Code tut etwas anderes als gedacht. Du trennst sie mit Reaktionszeit, Log und Einzelbild, bevor du einen Wert änderst. +goals: + - Du ordnest einen unerwarteten Treffer einer von drei Ursachen zu – Schwierigkeit, mangelnde Lesbarkeit oder technischer Fehler – und begründest die Zuordnung am Log. + - Du rechnest einen Telegraph in Millisekunden um und vergleichst ihn mit gemessenen Reaktionszeiten. + - Du gehst einen Treffer mit vier Fragen und den Werkzeugen der Testszene Frame für Frame durch. + - Du erkennst in einem Log die Zeile, die einen technischen Fehler zeigt. + - Du beschreibst, wie veröffentlichte Spiele die Schwierigkeit anpassen, und prüfst, was das für die Lesbarkeit bedeutet. +resources: + - title: Game Informer · Hidetaka Miyazaki über Schwierigkeit (2024) + url: https://gameinformer.com/interview/2024/06/24/from-softwares-hidetaka-miyazaki-discusses-the-studios-approach-to-difficulty + - title: Woods et al. · Factors influencing the latency of simple reaction time (2015) + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + - title: Mike Ambinder · Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + - title: Theodore Fishman · The lethality contract in Ghost of Tsushima (PlayStation Blog) + url: https://blog.playstation.com/?p=344024 + - title: Godot Docs · Overview of debugging tools + url: https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Drei Treffer, derselbe Satz: „Ich bin doch ausgewichen!“ Im ersten Fall steht im Log der Testszene `Druck dodge` in Frame 22. Die Figur wich auf der Stelle aus, ihre i-Frames liefen bis Frame 33. Die Hitbox des Gegners ging in Frame 30 an und blieb 20 Frames aktiv, also kam der Treffer in Frame 35, gleich nach den i-Frames. Das Ausweichen kam zu früh. Im zweiten Fall kam der Angriff von einem Gegner außerhalb des Bildes. Im dritten traf eine Hitbox an einer Stelle, an der das Bild nichts zeigte. Der erste Fall ist ein echter Lauf, die anderen beiden sind ausgedacht. + + Alle drei fühlen sich gleich an und brauchen verschiedene Antworten: üben, die Ankündigung ändern, den Code reparieren. Dieses Modul trennt die drei Ursachen und zeigt, wie du einen Treffer Frame für Frame nachvollziehst. Ob ein Kampf danach schwerer oder leichter werden soll, entscheidest du. + + ## Drei Ursachen: schwer, unlesbar, kaputt + + | Ursache | Woran du sie erkennst | Was hilft | + | --- | --- | --- | + | schwer | lesbar, Druck zu spät oder falsch | üben | + | unlesbar | Ankündigung fehlt, zu kurz, verdeckt | Telegraph, Kamera | + | kaputt | Log oder Bild widerspricht dem Code | Fehler beheben | + + „Unfair“ meint fast immer die zweite oder dritte Zeile. Schwer und fair schließen sich nicht aus. Hidetaka Miyazaki sagt für FromSoftware: „We believe in difficult games, but not games that are unjustly or unfairly so.“ Tode sollen sich „justified“ anfühlen ([Game Informer, 2024](https://gameinformer.com/interview/2024/06/24/from-softwares-hidetaka-miyazaki-discusses-the-studios-approach-to-difficulty)). Bei Furi war „fair“ laut The Game Bakers ein tägliches Schlagwort: „if they got hit, it was because they did a mistake“ ([GOG-Interview](https://gog.com/news/gog_interview_the_game_bakers_and_furis_success)). Mark Foster schreibt über die Bosse von Titan Souls, die Kämpfe seien „primarily meant to be predictable once the player understands them“ ([Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i-)). Für das rundenbasierte Monster Hunter Stories 2 ersetzte Capcom zufälliges Verhalten der Monster, das „was not popular among players“, durch Muster, die Spieler „predict and counter“ können ([Siliconera](https://www.siliconera.com/monster-hunter-stories-2-monster-behavior/)). + + Schwer heißt in diesem Sinn: Der Spieler kann sehen, was kommt, und lernen, was er tun muss. Ein Treffer sagt ihm dann etwas über seinen Fehler. Wie schwer dein Kampf sein soll, entscheidest du. Furi hat mit dem dritten und siebten Boss bewusst Spitzen gesetzt, „made to require perseverance“ ([DualShockers](https://www.dualshockers.com/furi-third-seventh-boss-hard/)). + + > [!MERKE] + > Erst die Ursache, dann der Wert. Wer einen kaputten Treffer mit einem längeren Telegraph „repariert“, hat danach zwei Probleme: den alten Fehler und einen Wert, den niemand gewählt hat. + + ## Lesbarkeit: Was du nicht siehst, kannst du nicht lernen + + Ein Angriff ist lesbar, wenn der Spieler rechtzeitig sieht oder hört, was kommt und wo. Greg Kasavin nennt für Hades als Ziel, die Action solle „very readable if a friend is next to you or if you're streaming it“ sein ([Game Developer](https://www.gamedeveloper.com/design/supergiant-s-fourth-outing-i-hades-i-introduces-a-more-mature-organized-dev-process)). Vier Stellen, an denen Lesbarkeit scheitert: + + - **Kein oder zu kurzer Telegraph.** Naughty Dog fasst frühere Nahkampfgegner mit „Short enemy attack tells“ und „Enemy starts swinging = certain hit“ zusammen (siehe [Ausweichen, Risiko und Belohnung](/combat/ausweichen)). Wie kurz zu kurz ist, prüfst du mit der Reaktionszeit im nächsten Abschnitt. + - **Außerhalb des Bildes.** Ein Angriff, dessen Urheber nicht im Bild ist, lässt sich nicht lesen. God of War (2018) zeigt solche Bedrohungen mit Pfeilen um die Figur ([Folien mit Notizen](https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf); mehr in [Gegnermuster und Telegraphing](/combat/gegnermuster)). + - **Vom großen Gegner verdeckt.** In der Draufsicht liegt ein großer Körper über dem Boden und über allem, was hinter ihm steht. Beim großen Trainingsgegner der Testszene ist die Telegraph-Fläche 64 × 40 Pixel groß, der Körper 48 × 48. Läge die Fläche mittig unter dem Körper und würde von ihm verdeckt, schauten links und rechts nur je 8 Pixel hervor (eigene Rechnung). Mehr dazu in [Große Kreaturen als Bosse](/combat/grosse-gegner). + - **Effekte verdecken Figuren.** Sakurai stellt für Trefferzeichen die Regel auf, dass sie die Figuren nicht verdecken dürfen ([Hit Marks](https://www.youtube.com/watch?v=B-P4ysHSjCg), 1:15 bis 3:46). Dasselbe gilt für Staub, Blitze und Partikel, die über einem Telegraph liegen (siehe [Trefferfeedback](/combat/trefferfeedback)). + + ### Reaktionszeit als Prüfstein + + Die **einfache Reaktionszeit** – ein bekanntes Signal, eine bekannte Taste – lag bei Woods et al. im Mittel bei 231 ms, nach Abzug der Geräteverzögerung bei 213 ms, also 13 bis 14 Frames ([Frontiers in Human Neuroscience](https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full)). Herleitung und Latenzkette stehen in [Eingabe: Reaktionszeit, Latenz und Puffer](/combat/eingabe). Im Spiel ist die Aufgabe schwerer: Der Spieler muss erkennen, welcher Angriff kommt, und eine Richtung wählen. Dazu kommt die Latenz des Systems, laut Mick West bei 60-fps-Spielen meist 4/60 s ([Game Developer](https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games)). + + Daraus folgt eine eigene Rechnung, keine Messung: Ist ein Telegraph kürzer als Reaktionszeit plus Latenz, kann der Spieler ihn nicht sehen und beantworten. Er kann ihn nur auswendig lernen, etwa weil er immer an derselben Stelle eines Musters kommt. Mit 14 Frames Reaktion und 4 Frames Latenz bleibt dieser Spielraum: + + | Telegraph | in ms | Spielraum | + | --- | --- | --- | + | 12 Frames | 200 | −6 Frames | + | 20 Frames | 333 | 2 Frames | + | 40 Frames | 667 | 22 Frames | + + Der Telegraph der Testszene mit 20 Frames lässt nach dieser Rechnung 2 Frames, für jemanden, der genau dieses Signal erwartet. Das Modell zählt den ersten, verwundbaren Frame des Ausweichens nicht mit: In der Testszene muss die Figur den Druck spätestens im letzten Telegraph-Frame sehen, dann bleibt 1 Frame. Ob ein Angriff Reagieren oder Erinnern verlangen soll, ist eine Designfrage. Die Rechnung sagt nur, welche der beiden Fähigkeiten er prüft. + + ## Kaputt: technische Fehler, die wie Unfairness aussehen + + Manche Treffer sind weder schwer noch unlesbar, sondern falsch: Der Code tut etwas anderes, als Bild und Vorgabe versprechen. Die häufigsten Fälle kennst du aus den Testszene-Modulen: + + | Symptom | Ursache | + | --- | --- | + | Treffer neben der Waffe | Hitbox sitzt anders als das Bild | + | Aktion kommt nicht | Druck nie angekommen | + | Laptop trifft länger | Timing hängt an der Bildrate | + | Nach i-Frames kein Treffer | Treffer nur abgelehnt | + | Ein Schlag zählt doppelt | zwei Zonen, kein `already_hit` | + | Gegner länger aktiv | Hitstop beim Ausweichen | + + **Hitbox und Bild.** Laut Sakurai ist es in Ordnung, wenn eine Hitbox größer ist als die Waffe, ein Unterschied in der Position nicht ([Always Keep Attack Collision in Mind](https://www.youtube.com/watch?v=rwwF_4blK-o), 1:02 bis 1:30). Dasselbe gilt für Telegraph und Hitbox: Ist die gelbe Fläche kleiner als die Hitbox, trifft der Angriff außerhalb seiner Ankündigung. + + **Verlorene Eingabe.** Die Zeile `Druck …` schreibt die Figur in dem Frame, in dem sie den Druck sieht. Fehlt sie, kam der Druck nie an: Input Map, Deadzone, Gerät. Steht sie da und die Aktion kam trotzdem nicht, war der Puffer abgelaufen, oder ein späterer Druck hat ihn überschrieben. Ein Druck aus Frame 32 ist in Frame 41 neun Frames alt und verfällt bei `input_buffer = 6` ([Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff)). Und weil die Testszene sich nur den letzten Druck merkt, verdrängt ein Angriff in Frame 38 ein Ausweichen aus Frame 36: In Frame 41 startet der Angriff, das Ausweichen fällt weg. Beides ist kein Fehler, der Code tut, was er soll. Ob der Puffer länger sein oder mehrere Drücke merken soll, entscheidest du. + + **Bildrate.** In [Godot-Start](/combat/godot-start) ist dieselbe Hitbox bei 30 Bildern pro Sekunde im Modus `Idle` 4 statt 3 Frames aktiv, mit Godot 4.7.2 gemessen. Teste deshalb auch bei gesenkter Bildrate. + + **i-Frames und Zonen.** Lehnt `take_hit()` Treffer während der i-Frames nur ab, trifft ein langer Angriff danach nie, weil `area_entered` nur das Hineinkommen meldet ([Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen)). Umgekehrt zählt ein Schlag doppelt, wenn er zwei Hurtboxen desselben Gegners berührt und sich nicht merkt, wen er schon getroffen hat. + + **Hitstop beim Ausweichen.** Setzt der Angreifer den Hitstop, bevor er weiß, ob der Treffer zählt, verlängert jedes Ausweichen seine aktive Phase. Im Log steht `ACTIVE -> RECOVERY` dann 4 Frames zu spät, und alle späteren Zeiten des Gegners verschieben sich um diese 4 Frames. + + > [!WARNING] Selten heißt nicht zufällig + > Ein Fehler, der nur jedes zehnte Mal auftritt, sieht aus wie Pech oder wie eigene Ungeschicklichkeit. Fast immer hängt er an einem bestimmten Frame: Druck im letzten möglichen Frame, Treffer im ersten Frame eines Zustands, Bildrate unter 60. Stell den Fall gezielt nach, bevor du ihn als Schwierigkeit abtust. + + ## Diagnose: vier Fragen an jeden Treffer + + Geh jeden Treffer, der sich falsch anfühlt, mit denselben vier Fragen durch, in dieser Reihenfolge: + + 1. **Was habe ich gesehen?** Telegraph, Gegner im Bild, eigene Figur sichtbar? Schreib es auf, bevor du ins Log schaust, sonst liest du hinein, was du erwartest. + 2. **Kam meine Eingabe an?** Zeile `Druck …` mit Frame, und in welchem Zustand die Figur war. + 3. **Wann war die Hitbox an?** `TELEGRAPH -> ACTIVE` und der Treffer einen Frame später. Lag der Druck vor oder nach dem letzten möglichen Frame? + 4. **Stimmte die Fläche?** Im Einzelbild mit sichtbaren Kollisionsformen: Liegt die Hitbox dort, wo Bild und Telegraph sie zeigen? + + Der erste Fall aus dem Einstieg, als echtes Log (Ausweichen auf der Stelle mit `dodge_speed = 0`, Angriff 20 Frames aktiv): + + ```text + 10 Dummy WAIT -> TELEGRAPH + 22 Druck dodge + 22 Spieler MOVE -> DODGE + 23 Spieler i-Frames an (Frame 2 …) + 30 Dummy TELEGRAPH -> ACTIVE + 34 Spieler i-Frames aus (Frame 13 …) + 35 Spieler getroffen in DODGE, Frame 13 + ``` + + Gesehen: gelbe Fläche ab Frame 10, 20 Frames lang. Der Druck kam an, in Frame 22. Die Hitbox ging in Frame 30 an und blieb an, als die i-Frames in Frame 34 endeten; der Treffer kam in Frame 35. Die Fläche stimmte, die Figur stand darin. Ergebnis: schwer, zu früh ausgewichen und stehen geblieben. Ob i-Frames einen langen Angriff ganz abdecken sollen, ist eine andere Frage, und die beantwortest du als Designer, nicht als Diagnostiker. Den umgekehrten Fall, einen Frame zu spät, behandelt [Ausweichen, Risiko und Belohnung](/combat/ausweichen). + + Die Werkzeuge dafür hast du am PC: + + - **HitLog** mit Frame-Nummer für jeden Druck, jeden Zustandswechsel und jeden Treffer. + - **Pause und Einzelbild**: F9 hält das Spiel im Game-Fenster an, F10 rückt einen Frame vor ([Game embedding](https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10)). + - **Kollisionsformen sichtbar** im Menü *Debug* ([Overview of debugging tools](https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes)). + - **Movie Maker**: `--write-movie` nimmt mit fester Bildrate auf, als PNG wird jedes Bild eine Datei ([Creating movies](https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html)). So siehst du einen Lauf später Bild für Bild an. + + Dein eigener Blick reicht nicht. Du kennst jeden Telegraph, also kannst du nicht prüfen, ob jemand ihn beim ersten Mal liest. Mike Ambinder rät für Valve, darauf zu achten, „what people do – not what they say“ ([Valve's Approach to Playtesting, PDF](https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf)). Eduardo Gorinstein, Combat Designer von Hades, sagt: „When I watch people play the game, they approach the fight in different ways“ ([DigiPen](https://digipen.edu/showcase/news/eduardo-gorinstein-delivers-challenge-and-fun-hades)). + + > [!PRAXIS] Keine Durchschnitte + > Ambinder warnt in denselben Folien: „Averages hide extreme examples.“ Stirbt eine Testerin zehnmal am selben Angriff und alle anderen nie, sagt der Durchschnitt „passt“. Notiere bei Playtests jeden Treffer, über den sich jemand beschwert, mit Uhrzeit oder Frame. Dann findest du ihn in Aufnahme und Log wieder und kannst ihn einer Ursache zuordnen. + + ## Schwierigkeit anpassen, ohne Lesbarkeit zu verlieren + + Ist ein Kampf schwer und lesbar, kann er trotzdem schwerer sein, als du willst. Veröffentlichte Spiele drehen an verschiedenen Stellen: + + - **Ghost of Tsushima.** Tester nannten Gegner „sword sponges“, es fühlte sich an „like I was hitting enemies with a foam bat“. Sucker Punch setzte eine harte Obergrenze für „hits to kill“. Die Schwierigkeitsgrade erhöhen nicht die Lebenspunkte der Gegner, Theodore Fishman nennt andere Hebel: Stagger, Block, Parry, Ausweichen, Angriffsgeschwindigkeit, Schaden ([PlayStation Blog](https://blog.playstation.com/?p=344024)). + - **Hades.** In den Patchnotes zum „Big Bad Update“ bekam Thanatos „increased preattack duration and recovery time“. Gegner in Mini-Kammern „take slightly longer to attack when you first enter“ ([Supergiant](https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/)). + - **Celeste.** Laut Vice beginnt der Text des Assist Mode mit „Celeste was designed to be a challenging, but accessible game…“. Maddy Thorson sagt: „Assist Mode breaks the game.“ ([Vice](https://www.vice.com/en/article/celeste-difficulty-assist-mode)). + - **Dynamische Schwierigkeit (DDA).** Das Spiel passt sich selbst an. Ernest Adams hält dagegen, dass sich „many players feel patronized“ und DDA „ruins pacing and obviates good level design“ ([Game Developer](https://www.gamedeveloper.com/design/feature-the-designer-s-notebook-difficulty-modes-and-dynamic-difficulty-adjustment-)). + - **Dead Cells.** Versteckte Hilfen, laut 80.lv „about 30-40 such small techniques“ ([80.lv](https://80.lv/articles/dead-cells-hidden-tricks-that-make-the-game-feel-fair)). Sébastien Benard zeigt unter anderem ein automatisches Umdrehen zum Gegner hinter dir und eine leichte Zielhilfe. Diese Hilfen hätten „much much more difficult combat“ ermöglicht ([GDC 2019](https://www.youtube.com/watch?v=OfSpBoA6TWw), ab etwa 18:49). + + Jeder Hebel setzt an einer anderen Stelle an: Lebenspunkte, Vorbereitung des Gegners, Hilfen für den Spieler, eine Wahl im Menü. Für die Lesbarkeit zählt, ob der Hebel die Zeit zum Reagieren verändert. Hades' längere Vorbereitung verlängert sie. Ein schnellerer Gegnerangriff verkürzt sie; dann rechnest du den Spielraum aus dem Abschnitt Reaktionszeit neu. Welchen Hebel du nimmst, entscheidest du. + + > [!INTERVIEW] + > „Ein Tester sagt, der Boss sei unfair. Was machst du?“ Gute Antwort: Frag nicht, ob er recht hat, sondern was passiert ist. Such den Moment in Aufnahme und Log und geh die vier Fragen durch: Was war zu sehen, kam die Eingabe an, wann war die Hitbox an, stimmte die Fläche? Ist es ein technischer Fehler, behebst du ihn zuerst. Ist es Lesbarkeit, änderst du Ankündigung, Kamera oder Effekte. Ist es Schwierigkeit, entscheidet das Team, ob sie gewollt ist, und ändert dann genau einen Wert. + + ## Häufige Fehler + + - **Werte ändern, bevor die Ursache feststeht.** Ein längerer Telegraph verdeckt einen Code-Fehler, statt ihn zu beheben. + - **Nur dem Gefühl trauen.** „Ich war rechtzeitig“ und „ich war einen Frame zu spät“ fühlen sich gleich an. Das Log unterscheidet sie. + - **Verlorenen, verfallenen und überschriebenen Druck verwechseln.** Fehlt die Zeile `Druck`, ist es ein Fehler. Steht sie da, ist der Druck verfallen oder von einem späteren verdrängt worden; beides folgt aus den Regeln des Puffers. + - **Vom Treffer-Frame statt vom Hitbox-Frame rechnen.** Der Treffer steht einen Frame nach `ACTIVE` im Log. Wer von dort zählt, verschiebt jedes Fenster um eins. + - **Eigene Tests als Lesbarkeitstest werten.** Wer den Angriff gebaut hat, erkennt ihn immer. + - **Schwierigkeit nur über Lebenspunkte.** Dann werden Gegner zu „sword sponges“. + - **Gegner schneller machen, ohne nachzurechnen.** Ein kürzerer Telegraph kann unter die Reaktionszeit fallen. + + ## Zusammenfassung + + - Ein Treffer, der sich falsch anfühlt, ist schwer, unlesbar oder kaputt. Jede Ursache braucht eine andere Antwort. + - Fair heißt: sehen, was kommt, und lernen können, was zu tun ist. Miyazaki, Furi und Titan Souls beschreiben das so. + - Lesbarkeit scheitert an fehlenden oder kurzen Telegraphs, an Gegnern außerhalb des Bildes, an Verdeckung durch große Körper und an Effekten. + - Einfache Reaktionszeit: 213 bis 231 ms, also 13 bis 14 Frames, plus Latenz des Systems. Kürzere Telegraphs prüfen Erinnern statt Reagieren (eigene Folgerung). + - Technische Fehler: Hitbox anders als das Bild, verlorene Eingabe, Timing an der Bildrate, i-Frames, doppelte Treffer, Hitstop beim Ausweichen. + - Vier Fragen: Was habe ich gesehen? Kam die Eingabe an? Wann war die Hitbox an? Stimmte die Fläche? + - Beobachte andere: was sie tun, nicht was sie sagen. Durchschnitte verstecken die Ausreißer. + - Schwierigkeit lässt sich an vielen Stellen anpassen. Prüf bei jedem Hebel, ob er die Zeit zum Reagieren verändert. +exercises: + - id: fall-zu-spaet + type: choice + title: "Fall 1: Druck nach dem Treffer" + prompt: | + Beispiel-Log, konstruiert nach der Logik der Testszene. Der Gegner kündigt seinen Angriff mit einer gut sichtbaren gelben Fläche an, die Figur steht darin. Schwer, unlesbar oder kaputt? + lang: text + code: | + 10 Gegner WAIT -> TELEGRAPH + 50 Gegner TELEGRAPH -> ACTIVE + 51 Spieler getroffen in MOVE, Frame 44 + 53 Druck dodge + options: + - text: Schwer – der Telegraph war 40 Frames lang, der Druck kam erst nach dem Treffer. + correct: true + why: 40 Frames sind 667 ms, weit über der Reaktionszeit. Der Code tat, was er soll; der Spieler war zu spät. + - text: Unlesbar – der Telegraph war zu kurz. + correct: false + why: 40 Frames lassen nach der Rechnung der Lektion über 20 Frames Spielraum. + - text: Kaputt – der Druck in Frame 53 ging verloren. + correct: false + why: Der Druck steht im Log, also kam er an. Er kam nur nach dem Treffer, als die Figur schon im Hitstun war. + explanation: | + Die Hitbox geht in Frame 50 an, der Treffer kommt einen Frame später, wie es sein soll. Der Druck steht im Log, aber zwei Frames nach dem Treffer. Der Telegraph lief 40 Frames, gut sichtbar. Damit bleibt nur eine Ursache: Der Spieler hat zu spät reagiert. Ob 40 Frames für dein Spiel zu lang oder zu kurz sind, ist eine andere Frage. + resources: + - title: Woods et al. 2015 · einfache Reaktionszeit + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + + - id: fall-sechs-frames + type: choice + title: "Fall 2: schneller als jede Reaktion" + prompt: | + Beispiel-Log, konstruiert nach der Logik der Testszene. Der Spieler sieht diesen Angriff zum ersten Mal. Schwer, unlesbar oder kaputt? + lang: text + code: | + 10 Gegner WAIT -> TELEGRAPH + 16 Gegner TELEGRAPH -> ACTIVE + 17 Spieler getroffen in MOVE, Frame 17 + 22 Druck dodge + options: + - text: Unlesbar – 6 Frames Telegraph sind kürzer als die Reaktionszeit. + correct: true + why: 6 Frames sind 100 ms. Die einfache Reaktionszeit liegt bei 213 bis 231 ms, ohne Latenz und ohne Entscheidung. + - text: Schwer – der Spieler hat 12 Frames gebraucht und war zu langsam. + correct: false + why: 12 Frames sind 200 ms. So schnell war bei Woods et al. im Mittel nur die jüngste Gruppe, und auch sie bräuchte doppelt so lang wie die 6 Frames. + - text: Kaputt – der Treffer kam einen Frame nach ACTIVE. + correct: false + why: Genau so soll es sein. Godot meldet die Überlappung einen Frame nach dem Einschalten der Hitbox. + explanation: | + Der Druck kam 12 Frames nach dem Beginn des Telegraphs, also schneller als die gemessene einfache Reaktionszeit, und trotzdem zu spät. Auf diesen Angriff kann beim ersten Mal niemand reagieren. Lernen kann man ihn nur, wenn er vorhersagbar kommt, etwa immer an derselben Stelle eines Musters. Ob er das soll, entscheidest du; als Lesbarkeitsproblem beim ersten Kontakt ist er eindeutig. + resources: + - title: Woods et al. 2015 · einfache Reaktionszeit + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + + - id: fall-nach-active + type: choice + title: "Fall 3: Treffer in der Erholung" + prompt: | + Beispiel-Log, konstruiert nach der Logik der Testszene. Der Gegner hat einen Angriff mit 4 aktiven Frames. Schwer, unlesbar oder kaputt? + lang: text + code: | + 10 Gegner WAIT -> TELEGRAPH + 30 Gegner TELEGRAPH -> ACTIVE + 34 Gegner ACTIVE -> RECOVERY + 38 Spieler getroffen in MOVE, Frame 12 + options: + - text: Kaputt – die Hitbox war in der Erholung noch an. + correct: true + why: Ab Frame 34 ist der Gegner in der Erholung, seine Hitbox muss aus sein. Ein Treffer vier Frames später heißt, sie wurde nicht abgeschaltet. + - text: Schwer – der Spieler ist zu früh in den Gegner gelaufen. + correct: false + why: In der Erholung soll der Gegner gar nicht treffen. Laufen kann der Spieler dort, wohin er will. + - text: Unlesbar – die Erholung war nicht angekündigt. + correct: false + why: Eine Erholung braucht keine Ankündigung, weil sie nicht trifft. Hier hat der Code etwas getan, was keine Vorgabe vorsieht. + explanation: | + Die aktive Phase lief von Frame 30 bis 33. Ein Treffer aus diesen Frames stünde spätestens in Frame 34 im Log. Frame 38 passt zu keinem Zustand, in dem der Gegner treffen darf. Solche Treffer fühlen sich für den Spieler wie Unfairness an, sind aber ein Fehler im Code, etwa eine Hitbox, die beim Zustandswechsel nicht abgeschaltet wird. Erst beheben, dann weiter abstimmen. + resources: + - title: Godot Docs · CollisionShape2D.disabled + url: https://docs.godotengine.org/en/stable/classes/class_collisionshape2d.html#class-collisionshape2d-property-disabled + + - id: fehler-nachstellen + type: order + title: Ein seltener Treffer, gezielt nachgestellt + prompt: | + Ein Tester meldet einen Treffer, der „nur manchmal“ passiert. Bring die Schritte in eine Reihenfolge, mit der du ihn am PC gezielt nachstellst. + lang: text + lines: | + Meldung mit Uhrzeit oder Frame notieren + Den Moment in Log und Aufnahme suchen + Druck- und Hitbox-Frames ablesen + Drehbuch mit genau diesen Frames schreiben + Lauf mit --fixed-fps 60 wiederholen + Prüfen, ob der Treffer jedes Mal kommt + explanation: | + Selten heißt fast nie zufällig, sondern: an einen bestimmten Frame gebunden. Mit Zeitpunkt findest du den Moment in Log und Aufnahme wieder. Aus dem Log liest du ab, in welchen Frames gedrückt wurde und die Hitbox anging. Das [Drehbuch](/combat/testszene-angriff) drückt die Tasten dann genau in diesen Frames, und eine feste Bildrate macht den Lauf wiederholbar. Kommt der Treffer jetzt jedes Mal, hast du den Fall und kannst die Ursache bestimmen. + resources: + - title: Godot Docs · Command line tutorial + url: https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html + + - id: telegraph-in-ms + type: output + title: Zwölf Frames in Millisekunden + prompt: | + Ein Telegraph dauert 12 Frames bei 60 Frames pro Sekunde. Wie viele Millisekunden sind das? Antworte mit der Zahl. + lang: text + code: | + ms = Frames × 1000 / 60 + Telegraph: 12 Frames + expected: | + 200 + accept: + - 200 ms + explanation: | + 12 × 1000 / 60 = 200 ms. Das ist weniger als die einfache Reaktionszeit von 213 ms, die Woods et al. nach Abzug der Geräteverzögerung gemessen haben. Mit Latenz des Systems und der Entscheidung, wohin man ausweicht, fehlt noch mehr. Ein solcher Telegraph lässt sich nicht sehen und beantworten, nur erwarten. + resources: + - title: Woods et al. 2015 · einfache Reaktionszeit + url: https://www.frontiersin.org/journals/human-neuroscience/articles/10.3389/fnhum.2015.00131/full + + - id: spaetester-druck + type: output + title: Der letzte mögliche Frame + prompt: | + Ein Telegraph beginnt in Frame 50 und dauert 24 Frames. Die i-Frames beginnen im zweiten Frame des Ausweichens und decken alle aktiven Frames ab. Die Figur steht in der Fläche und weicht auf der Stelle aus. In welchem Frame muss die Figur den Druck spätestens sehen, damit kein Treffer kommt? + lang: text + code: | + 50 Gegner WAIT -> TELEGRAPH + ??? Druck dodge + expected: | + 73 + accept: + - Frame 73 + hints: + - In welchem Frame geht die Hitbox an? + - Im echten Log der Testszene geht die Hitbox in Frame 30 an, und der Druck in Frame 29 reicht gerade noch. + explanation: | + Der Telegraph belegt die Frames 50 bis 73, in Frame 74 geht die Hitbox an. Ein Druck in Frame 73 startet das Ausweichen, Frame 74 ist dessen zweiter Frame, die i-Frames beginnen, und der Physik-Schritt findet keine Hurtbox. Ein Druck in Frame 74 wäre einen Frame zu spät: Der Treffer käme in Frame 75 im ersten, verwundbaren Frame des Ausweichens. Im echten Lauf der Testszene ist es dasselbe Muster mit 29 und 30. + resources: + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html + + - id: spielraum-rechnen + type: output + title: Wie viel Spielraum bleibt? + prompt: | + Dasselbe Modell wie in der Lektion, aber mit 13 Frames Reaktion und 6 Frames Latenz (4 Frames plus 2 für einen Flachbildschirm). Was gibt das Programm aus? + lang: python + code: | + REAKTION = 13 # Frames, etwa 213 ms + LATENZ = 6 # 4 Frames + 2 Bildschirm + + def spielraum(telegraph): + return telegraph - REAKTION - LATENZ + + print([spielraum(t) for t in (16, 24, 30)]) + expected: | + [-3, 5, 11] + explanation: | + Reaktion und Latenz ergeben zusammen 19 Frames. Ein Telegraph von 16 Frames lässt dann 3 Frames zu wenig, 24 Frames lassen 5 übrig, 30 Frames 11. Das Modell ist eine eigene Rechnung: Es nimmt die einfache Reaktionszeit, also die schnellste Form, und ignoriert die Entscheidung, wohin der Spieler ausweicht. Ein negativer Spielraum heißt nur, dass der Spieler den Angriff erwarten muss, nicht dass er schlecht ist. + resources: + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + + - id: diagnose-notiz + type: gap + title: Eine Diagnose-Notiz ausfüllen + prompt: | + Echtes Log der Testszene aus der Lektion: Telegraph ab Frame 10, die Figur weicht auf der Stelle aus, der Angriff ist 20 Frames aktiv. Fülle die Notiz aus. + lang: text + code: | + 22 Druck dodge + 23 Spieler i-Frames an + 30 Dummy TELEGRAPH -> ACTIVE + 34 Spieler i-Frames aus + 35 Spieler getroffen + + Telegraph lang: ⟦20⟧ Frames + Druck gesehen: Frame ⟦22⟧ + Hitbox an: Frame ⟦30⟧ + geschützt bis: Frame ⟦33⟧ + Treffer: Frame ⟦35⟧ + gaps: + - label: Länge des Telegraphs + hint: Von Frame 10 bis zum Wechsel nach ACTIVE. + - label: Druck + hint: Die Zeile „Druck dodge“. + - label: Hitbox + hint: Die Zeile „TELEGRAPH -> ACTIVE“. + - label: Letzter geschützter Frame + hint: Der Frame vor „i-Frames aus“. + - label: Treffer + hint: Die Zeile „Spieler getroffen“. + explanation: | + Der Telegraph lief von Frame 10 bis 29, also 20 Frames. Der Druck kam in Frame 22, die i-Frames liefen von 23 bis 33. In Frame 34 schaltete die Figur ihre Hurtbox wieder ein, die Hitbox war seit Frame 30 an, und die Physik meldete die Überlappung in Frame 35. Das Ausweichen kam zu früh für einen Angriff, der 20 Frames trifft. So eine Notiz schreibst du für jeden Treffer, den du untersuchst. + resources: + - title: Godot Docs · Area2D.monitorable + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-property-monitorable + + - id: log-hitstop-ausweichen + type: bug + title: Ein Ausweichen, das den Gegner verlängert + prompt: | + Beispiel-Log einer früheren Fassung der Testszene, in der `take_hit()` Treffer während der i-Frames nur ablehnt. Der Dummy hat 4 aktive Frames und Hitstop 4. Die Figur wurde nicht getroffen, trotzdem liegen die Zeiten des Dummys 4 Frames später als vorhergesagt. Tippe die Zeile an, die den technischen Fehler zeigt, und schreib, wie sie ohne den Fehler lauten müsste. + lang: text + code: | + 28 Druck dodge + 28 Spieler MOVE -> DODGE + 30 Dummy TELEGRAPH -> ACTIVE + 31 Spieler weicht aus (Frame 3 des Ausweichens) + 38 Dummy ACTIVE -> RECOVERY + lines: [5] + fix: + - line: 5 + answers: + - 34 Dummy ACTIVE -> RECOVERY + explanation: | + Mit 4 aktiven Frames ab Frame 30 beginnt die Erholung in Frame 34. Frame 38 ist 4 Frames später, genau der Hitstop. Der Dummy hat ihn gesetzt, obwohl die Figur den Treffer abgelehnt hat. Alle Zeiten des Gegners verschieben sich, und wer auf die vorhergesagten Frames hin plant, liegt daneben. Die Ursache steckt im Code des Angreifers, siehe [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen). + + - id: reagieren-oder-erinnern + type: explain + title: Acht Frames und ein Muster + prompt: | + Ein Bossangriff hat einen Telegraph von 8 Frames und kommt immer als dritter Angriff im Muster. Erkläre mit der Reaktionszeit, was der Spieler können muss, um auszuweichen, und warum die Rechnung nicht sagt, ob der Angriff gut ist. + lang: text + code: | + Muster des Bosses (Beispiel, Platzhalter) + 1. Stampfer Telegraph 40 Frames + 2. Hieb Telegraph 24 Frames + 3. Stoß Telegraph 8 Frames + danach von vorn + points: + - 8 Frames sind 133 ms, kürzer als die einfache Reaktionszeit von 213 bis 231 ms (13 bis 14 Frames). + - Im Spiel kommen Erkennen, Entscheiden und die Latenz des Systems dazu; die einfache Reaktionszeit ist die Untergrenze. + - Ausweichen gelingt nur, wenn der Spieler den Angriff vorher erwartet, hier aus seiner Stelle im Muster. + - Ob ein Angriff Erinnern statt Reagieren verlangen soll, ist eine Designentscheidung; die Rechnung beschreibt nur, welche Fähigkeit er prüft. + explanation: | + 8 Frames sind 133 ms. Woods et al. messen für die schnellste Form der Reaktion, ein bekanntes Signal und eine bekannte Taste, im Mittel 213 bis 231 ms. Im Spiel kommt die Latenz des Systems hinzu, und der Spieler muss eine Richtung wählen. Auf diesen Angriff kann also niemand reagieren, aber jeder kann ihn erwarten, weil er immer an dritter Stelle kommt. Titan Souls will genau das: vorhersagbar, sobald man den Kampf versteht. Ob dein Boss Erinnern prüfen soll, entscheidest du. Die Rechnung macht nur sichtbar, dass er es tut. + resources: + - title: Mark Foster · One-hit kills in Titan Souls (Game Developer) + url: https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i- + + - id: hits-to-kill + type: explain + title: Schwerer, ohne Schwamm + prompt: | + Erkläre, warum Sucker Punch die Schwierigkeit in Ghost of Tsushima nicht über die Lebenspunkte der Gegner steigert, und was du prüfen musst, wenn du stattdessen Gegnerangriffe schneller machst. + lang: text + code: | + Ghost of Tsushima laut Theodore Fishman: + Tester: „sword sponges“, „foam bat“ + Regel: harte Obergrenze für „hits to kill“ + Stufen: erhöhen nicht die Gegner-HP + Hebel: Stagger, Block, Parry, Ausweichen, + Angriffsgeschwindigkeit, Schaden + points: + - Mehr Lebenspunkte heißen mehr Treffer pro Gegner; Tester empfanden Gegner als „sword sponges“, Schläge wie mit einem „foam bat“. + - Eine Obergrenze für „hits to kill“ hält fest, wie tödlich ein Schlag ist, auch auf hohen Schwierigkeitsgraden. + - Schwierigkeit kommt aus anderen Hebeln wie Block, Parry, Ausweichen, Angriffsgeschwindigkeit und Schaden. + - Schnellere Gegnerangriffe verkürzen die Zeit zum Reagieren; der Spielraum gegen die Reaktionszeit muss neu gerechnet werden. + explanation: | + Theodore Fishman beschreibt, dass sich Kämpfe mit Gegnern, die viele Treffer aushalten, für Tester anfühlten „like I was hitting enemies with a foam bat“. Die harte Grenze für „hits to kill“ bewahrt die Tödlichkeit des Schwerts auf jedem Schwierigkeitsgrad. Schwerer wird es über andere Hebel, darunter die Angriffsgeschwindigkeit der Gegner. Genau dieser Hebel greift die Lesbarkeit an: Wird ein Telegraph kürzer, kann er unter Reaktionszeit plus Latenz fallen. Wer an ihm dreht, rechnet den Spielraum neu. + resources: + - title: PlayStation Blog · The lethality contract in Ghost of Tsushima + url: https://blog.playstation.com/?p=344024 + - title: GDC 2021 · Honoring the Blade (YouTube) + url: https://www.youtube.com/watch?v=dKS2kaI3aXE + + - id: clip-drei-treffer + type: practice + title: "Clip-Analyse: drei Treffer zuordnen" + prompt: | + Sieh dir einen Bosskampf-Clip an, zum Beispiel [How Cuphead's Bosses (Try to) Kill You](https://www.youtube.com/watch?v=F8T6Ul4aHTI) von Game Maker's Toolkit, oder ein Video eines Spiels, das du kennst. Such drei Momente, in denen der Spieler getroffen wird. Geh für jeden die erste und die vierte Diagnosefrage durch, so weit ein Video das zulässt, und ordne ihn einer Ursache zu. + lang: text + solution: | + Beispiel für die Form, ausgedachte Einträge: + + Treffer 1 (Zeit im Clip notieren) + Gesehen: Boss hebt den Arm, Schatten am Boden + Telegraph gezählt: etwa 30 Frames + (mit Sakurais „1-und-2-und-3“: ein Schlag ~30) + Fläche: Treffer dort, wo der Schatten war + Ursache: schwer, Spieler sprang zu spät + + Treffer 2 + Gesehen: Projektil kommt vom Bildrand + Telegraph: keiner im Bild erkennbar + Ursache: unlesbar? Unsicher, das Video zeigt + nicht, ob es einen Ton gab. + checklist: + - Du hast für jeden der drei Treffer die Stelle im Clip notiert. + - Zu jedem Treffer steht, was vor dem Treffer zu sehen war, und eine geschätzte Länge des Telegraphs in Frames. + - Jeder Treffer ist einer Ursache zugeordnet, mit einem Satz Begründung. + - Wo ein Video die Frage nicht beantworten kann (Eingabe, Hitbox, Ton), hast du das als unsicher markiert statt zu raten. + explanation: | + Von außen siehst du nur zwei der vier Fragen: was zu sehen war und ob die Fläche zum Bild passte. Ob eine Eingabe ankam oder wann eine Hitbox anging, verrät kein Video. Deshalb ist „kaputt“ in einer Clip-Analyse fast nie sicher, „schwer“ und „unlesbar“ oft schon. Zum Frame-Zählen hilft Sakurais Methode aus [Learn to Count Frames!](https://www.youtube.com/watch?v=JZ1Jd0u3b6U): Bei „1-und-2-und-3“ dauert jeder Schlag etwa 30 Frames. Welche Ursache du bei unsicheren Fällen annimmst, ist deine Einschätzung. + resources: + - title: Masahiro Sakurai · Learn to Count Frames! + url: https://www.youtube.com/watch?v=JZ1Jd0u3b6U + + - id: eigener-treffer + type: practice + title: Ein Treffer, der sich unfair angefühlt hat + prompt: | + Denk an einen Treffer aus einem Spiel, das du spielst, oder aus der Testszene, der sich unfair angefühlt hat. Schreib eine Diagnose-Notiz mit den vier Fragen. Ordne den Treffer einer Ursache zu und notiere, was du ändern würdest, wenn es dein Spiel wäre, ohne einen Wert festzulegen. + lang: text + solution: | + Beispiel für die Form, am neutralen Trainingsgegner: + + Gesehen: gelbe Fläche, Figur stand darin + Eingabe: 22 Druck dodge + Hitbox: 30 Dummy TELEGRAPH -> ACTIVE + 34 i-Frames aus, 35 getroffen + Fläche: F10 und Kollisionsformen: Figur in der + Hitbox, Hitbox so groß wie die gelbe Fläche + Ursache: schwer, zu früh ausgewichen + Wenn es mein Spiel wäre: Frage offen, ob die + i-Frames lange Angriffe abdecken sollen. + Erst testen, dann entscheiden. + checklist: + - Die Notiz beantwortet alle vier Fragen, oder sie sagt, welche du nicht beantworten konntest. + - Was du gesehen hast, stand fest, bevor du Log oder Aufnahme angesehen hast. + - Die Ursache ist begründet, nicht nur benannt. + - Was du ändern würdest, ist eine Frage oder Hypothese, kein fester Wert. + explanation: | + Die Übung macht aus einem Gefühl eine Beobachtung. Erst das Gesehene, dann die Daten, dann die Zuordnung. Bei einem Spiel, das du nicht gebaut hast, bleiben Fragen offen. Das ist in Ordnung, solange du sie als offen markierst. Was du ändern würdest, bleibt eine Hypothese. Wie du sie prüfst, steht in [Tuning als Experiment](/combat/tuning). + resources: + - title: Mike Ambinder · Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf diff --git a/content/combat/modules/gegnermuster.yaml b/content/combat/modules/gegnermuster.yaml new file mode 100644 index 0000000..2db4d00 --- /dev/null +++ b/content/combat/modules/gegnermuster.yaml @@ -0,0 +1,453 @@ +id: gegnermuster +title: Gegnermuster und Telegraphing +level: 2 +minutes: 110 +summary: Wofür ein Gegner im Kampf da ist, wer wann angreifen darf, über welche Kanäle sich ein Angriff ankündigt und warum Muster besser lesbar sind als Zufall – an God of War, Monster Hunter, Hades und dem Trainingsgegner der Testszene. +goals: + - Du beschreibst die Rolle eines Gegners als Frage an den Spieler und ordnest ihr messbare Größen zu. + - Du erklärst Aggressions-Tokens und den Kung-Fu-Kreis aus God of War und rechnest aus, wer aggressiv wird. + - Du unterscheidest Telegraph-Kanäle (Pose, Blinken, Bodenmarkierung, Ton) und sagst, welche Frage jeder beantwortet. + - Du liest das Muster eines Gegners aus einem Log und sagst vorher, wann der nächste Angriff angekündigt wird. + - Du übersetzt echte Tuning-Änderungen aus Patchnotes in Felder der Testszene. +resources: + - title: Evolving Combat in God of War for a New Perspective (GDC 2019, Folien mit Notizen) + url: https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf + - title: Hades · The Big Bad Update Patch Notes (Supergiant Games) + url: https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/ + - title: Melee AI in The Last of Us Part II (GDC 2021, Folien) + url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf + - title: Vice · The Developers of Monster Hunter Explain What It's Like to Build Monsters + url: https://vice.com/en/article/the-developers-of-monster-hunter-explain-what-its-like-to-build-monsters +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Der Trainingsgegner der Testszene besteht aus wenigen Zahlen: 60 Frames warten, 20 Frames gelbe Bodenfläche, 4 Frames Hitbox, 30 Frames Erholung. Ein Frame ist ein Physik-Tick, 1/60 Sekunde. Das reicht für einen Angriff. Ein Gegner wird daraus erst, wenn klar ist, wann er angreift, woran der Spieler das erkennt und was danach kommt. Kaname Fujioka beschreibt die frühen Monster von Monster Hunter mit „relatively simple attacks, easy tells“: Wenn ein Monster das tut, weißt du, dass es gleich auf dich zustürmt ([Vice](https://vice.com/en/article/the-developers-of-monster-hunter-explain-what-its-like-to-build-monsters)). + + Ein **Telegraph** ist die Ankündigung eines Angriffs, ein **Tell** das einzelne Signal darin. Dieses Modul behandelt die Bausteine: welche Rolle ein Gegner spielt, wer wann angreifen darf, über welche Kanäle sich ein Angriff ankündigt, warum Muster lesbarer sind als Zufall und wie Studios das nach Playtests nachstellen. Alle Werte der Testszene sind Platzhalter. Wie deine Gegner aussehen, entscheidest du. + + ## Rollen: wofür ein Gegner im Kampf da ist + + Bei Monster Hunter steht die Rolle vor dem Monster. Fujioka sagt: „We need a framework for what kind of monster is going to be needed at each stage of the game“, und: „They all have a different role to play.“ Ein Monster, das am Anfang alle Werkzeuge des Spielers verlangt, passt dort nicht hin ([Vice](https://vice.com/en/article/the-developers-of-monster-hunter-explain-what-its-like-to-build-monsters)). In einem anderen Interview beschreibt er die Reihenfolge der Arbeit: „We plan for the monster's behaviour first“, mit Dingen wie dem Abstand zwischen Spieler und Monster ([Nintendo Life](https://nintendolife.com/news/2015/02/monster_hunter_4_ultimate_director_discusses_monster_design_and_animation)). + + Eine Rolle kann auch eine Ressource sein. id Software beschreibt im Vortrag zu DOOM (2016) einen Kampf ohne regenerierende Gesundheit, ohne Nachladen und ohne Deckung: Was der Spieler braucht, holt er sich von den Gegnern ([Game Developer](https://www.gamedeveloper.com/design/video-the-combat-design-of-i-doom-i-)). Und Rollen können wandern. Bei Hollow Knight: Silksong nutzt laut William Pellen schon ein gewöhnlicher Ameisenkrieger dasselbe Moveset wie der ursprüngliche Hornet-Boss aus Hollow Knight ([Nintendo Life](https://www.nintendolife.com/news/2025/09/team-cherry-explains-hollow-knight-silksongs-steep-difficulty)). + + Eine Rolle ist eine Frage an den Spieler. Damit du sie abstimmen kannst, braucht sie eine Größe. Ein paar Beispiele, wie sich solche Fragen auf die Testszene abbilden: + + | Frage an den Spieler | Größe | Feld | + | --- | --- | --- | + | Hältst du Druck aus? | Pause | `wait_frames` | + | Erkennst du den Angriff? | Ankündigung | `windup` | + | Nutzt du die Lücke? | Erholung | `recovery` | + | Hältst du Abstand? | Reichweite | Hitbox-Form | + + Welche Fragen deine Gegner stellen, legst du fest. Die Tabelle zeigt nur, dass jede Frage an einer Zahl hängt, die du ändern und messen kannst. + + ## Wer greift an? Aggression, Tokens und der Kung-Fu-Kreis + + Vier Gegner, die jeder nach eigener Uhr angreifen, treffen irgendwann im selben Frame. Für God of War (2018) hat Santa Monica Studio dafür ein System übernommen und überarbeitet, das laut Mihir Sheth „mostly updated … from GOW: Ascension“ ist: „Think of it like a fight scene in a kung fu movie.“ Aggressive Gegner greifen von vorn an, die anderen „hang around in the back and wait to become aggressive“ ([Folien mit Notizen](https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf)). + + Wer aggressiv ist, entscheidet ein Wert, den das Spiel in kurzen Abständen neu berechnet. Er setzt sich aus vier Kriterien zusammen, nach Wichtigkeit geordnet: + + 1. Kann der Gegner überhaupt aggressiv werden? Nicht, solange er etwa gerade getroffen reagiert. + 2. Seine Priorität, die Designer je Gegnertyp festlegen, mit einer Reichweite, in der sie gilt. + 3. Ob der Spieler ihn gerade anvisiert. + 4. Sein Rang aus Position: im Bild oder nicht, Winkel zur Kamera, Abstand zu Kratos. + + Nach diesem Wert sortiert, bekommen die Gegner **Aggressions-Tokens** aus einem festen Vorrat. Ein Gegnertyp kann mehrere Tokens belegen, „to signify size or importance“. Im Beispiel der Folien hat der Vorrat 14 Tokens. Ist er aufgebraucht, sind alle übrigen nicht aggressiv. Die Werte ändern sich je Schwierigkeitsgrad. + + Die Analyse von Tommy Thompson zu DOOM (2016) beschreibt ein ähnliches System, aber je Angriffsart: Nahkampf, Fernkampf, aufgeladene Angriffe und andere haben eigene Token-Zahlen, je Schwierigkeit verschieden, und Dämonen können einander Tokens wegnehmen. Während eines Glory Kills, des Finishers des Spielers, darf keine KI einen neuen Angriff beginnen, bis er vorbei ist, weil es unfair wäre, wenn Dämonen angreifen, während der Spieler nicht reagieren kann ([Game Developer](https://www.gamedeveloper.com/design/cyber-demons-the-ai-of-doom-2016-)). Das ist eine Sekundärquelle, keine Aussage von id Software. + + Eine Anleitung zum Nachbauen ist der Artikel [Battle Circle AI](https://code.tutsplus.com/battle-circle-ai-let-your-player-feel-like-theyre-fighting-lots-of-enemies--gamedev-13535t) von Kenneth Backus. Er unterscheidet „cinematic“ Kämpfe, in denen Gegner warten, bis sie dran sind, von „dangerous“ wie in Dark Souls, in denen sie sich nur aus dem Weg gehen. Die Variable `simultaneousAttackers` legt fest, wie viele gleichzeitig angreifen dürfen: in seiner Demo 2, in cineastischeren Spielen 1. Wer abgewiesen wird, weicht „for a second or two“ seitlich aus und fragt dann wieder. + + Die Trainingsgegner der Testszene kennen keine Tokens. Jeder läuft seinen eigenen Zyklus. Ob und wie deine Gegner sich absprechen, ist eine eigene Schicht über ihrem Zustandsautomaten, und eine eigene Designentscheidung. + + ## Telegraph-Kanäle: Pose, Blinken, Bodenmarkierung, Ton + + Ein Angriff kann sich über mehrere Kanäle ankündigen. Jeder beantwortet eine andere Frage: + + - **Pose**: Wer greift an, und wann ungefähr? Die Ausholbewegung der Animation (mehr im geplanten Kurs zu 2D-Animation). + - **Blinken**: Jetzt gleich. Ein kurzer Farbwechsel oder Flash markiert einen Zeitpunkt. + - **Bodenmarkierung**: Wo und wie groß? Eine Fläche zeigt, was getroffen wird. + - **Ton**: auch ohne Blickkontakt. Ein Geräusch erreicht den Spieler, wenn die Kamera den Gegner nicht zeigt (mehr im geplanten Kurs zu Sounddesign). + + Die Testszene nutzt nur die Bodenmarkierung. Die gelbe Fläche ist genau im Zustand `TELEGRAPH` sichtbar, und dieser dauert so lange wie `windup` des aktuellen Angriffs: + + ```gdscript + telegraph.visible = new_state == State.TELEGRAPH + ``` + + Wie lang und wie deutlich ein Telegraph ist, entscheidet, ob man auf ihn reagieren kann. Für The Last of Us Part II hat Naughty Dog die Tells der Gegner verlängert und deutlicher gemacht, als das Ausweichen dazukam: „Longer enemy attack tells. More obvious enemy attack tells.“ ([GDC-Folien](https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf)). Monster Hunter kündigt auch über längere Zeit an: Fujioka beschreibt Hinweise aus Zuständen „such as alarmed, enraged, exhausted“ ([Nintendo Life](https://nintendolife.com/news/2015/02/monster_hunter_4_ultimate_director_discusses_monster_design_and_animation)). Supergiant wollte die Action von Hades „very readable if a friend is next to you or if you're streaming it“ ([Game Developer](https://www.gamedeveloper.com/design/supergiant-s-fourth-outing-i-hades-i-introduces-a-more-mature-organized-dev-process)). + + ### Bedrohungen außerhalb des Bildes + + Die Kamera von God of War steht nah hinter Kratos. Gegner verschwinden aus dem Bild, und einer davon kann jederzeit aggressiv werden. Das Team ließ zuerst den Bildrand auf der Seite des Gegners blinken. Spieler hielten das für Schadensanzeigen. Am Ende kreisen Pfeile um Kratos und zeigen auf die Gegner: rot für einen kommenden Angriff, dezent weiß für wartende Gegner in der Nähe, lila für Fernkampf ([Folien mit Notizen](https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf)). Dazu kam eine Regel für die Positionierung: Ein Gegner außerhalb des Bildes, der aggressiv wird, „should NOT be forced on screen again until you looked at it“, und er bleibt in seinem Quadranten relativ zu Kratos. So bauen Spieler eine Vorstellung davon auf, wo wer steht. + + In der Draufsicht mit 320 × 180 Pixeln sieht die Kamera mehr, aber nicht alles. Ein Fernkämpfer außerhalb des Bildes stellt dieselbe Frage: Wie erfährt der Spieler, dass gleich etwas kommt? + + > [!PRAXIS] Positionierung kam spät + > Laut Sheth hat das Team die Positionierung der Gegner erst „during the last few months during intense playtesting“ noch einmal grundlegend untersucht. Gegnerverhalten wird in Playtests fertig, nicht auf dem Papier. Plane deshalb Zeit für Runden ein, in denen du nur beobachtest, wann Tester einen Angriff übersehen. + + ## Muster statt Zufall + + Für das rundenbasierte Monster Hunter Stories 2 hat Capcom zufälliges Gegnerverhalten durch Muster ersetzt. Das Zufallselement „was not popular among players“; jedes Monster bekommt jetzt ein Verhaltensmuster, das Spielern erlaubt, Angriffe „to more easily predict and counter“ ([Siliconera](https://www.siliconera.com/monster-hunter-stories-2-monster-behavior/)). Dort geht es um das Vorhersagen der Reihenfolge, denn in einem Rundenkampf wählt man vor dem Angriff. In einem Actionspiel kommt etwas Zweites dazu: einen Angriff an seiner Ankündigung erkennen. Ein Muster macht die Reihenfolge lernbar; eine gleichbleibende Ankündigung macht den einzelnen Angriff lesbar, auch wenn die Reihenfolge zufällig ist. + + Der Trainingsgegner der Testszene spielt seine Liste `attacks` der Reihe nach ab und fängt danach von vorn an. Ein gemessener Lauf mit zwei Angriffen, A (Wind-up 20, aktiv 4, Erholung 30) und B (Wind-up 30, aktiv 6, Erholung 20), Pause jeweils 60 Frames, alles Platzhalter: + + ```text + 60 Dummy WAIT -> TELEGRAPH + 80 Dummy TELEGRAPH -> ACTIVE + 84 Dummy ACTIVE -> RECOVERY + 114 Dummy RECOVERY -> WAIT + 174 Dummy WAIT -> TELEGRAPH + 204 Dummy TELEGRAPH -> ACTIVE + 210 Dummy ACTIVE -> RECOVERY + 230 Dummy RECOVERY -> WAIT + 290 Dummy WAIT -> TELEGRAPH + ``` + + Ein Durchlauf von A und B dauert 230 Frames, knapp vier Sekunden. Nach Frame 290 beginnt er von vorn. Wer das Muster kennt, weiß nach A, dass B kommt, und weiß, dass B zehn Frames länger angekündigt wird. + + Variation ohne Lesbarkeit zu verlieren heißt: Die Reihenfolge darf wechseln, der Tell eines Angriffs nicht. Gleicher Angriff, gleiche Ankündigung. Für Silksong hat Team Cherry geändert, wie Bosse ihre Angriffe wählen: „we reworked how the bosses pick their attacks“. Sie entscheiden zuerst, was sie tun wollen, „and then move into the correct position to do it“; in Hollow Knight richteten sie sich laut dem Bericht stärker nach der Position des Spielers ([Military.com](https://www.military.com/off-duty/games/silksong-devs-explain-how-bosses-were-designed-differently-those-hollow-knight.html)). Bei Enter the Gungeon experimentierte David Rubel mit Kugelmustern, zu denen das ganze Team Rückmeldung gab ([Game Developer](https://www.gamedeveloper.com/design/q-a-the-guns-and-dungeons-of-i-enter-the-gungeon-i-)). + + Zum Weiterdenken: der Clip [What Makes Good AI?](https://www.youtube.com/watch?v=9bbhJi0NBkk) von Game Maker's Toolkit. Achte darauf, welche Gegnerentscheidungen du als Spieler vorhersagen könntest. + + ## Tuning nach Playtests: Beispiele aus den Hades-Patchnotes + + Supergiant hat Hades im Early Access veröffentlicht und Änderungen an Gegnern in Patchnotes dokumentiert. Das Big Bad Update von 2019 enthält Zeilen, die direkt auf Telegraph und Erholung zielen ([Patchnotes](https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/)): + + - Patch 025: „Thanatos: reduced frequency of his area attack; also increased preattack duration and recovery time“ + - Patch 026: „Foes in Temple of Styx mini-chambers take slightly longer to attack when you first enter“ + - Patch 026, Endboss: „reduced blast speed of shockwave attack; reduced tracking of homing attack“ + - Patch 026: „Snakestone: increased damage, increased tracking speed“, Patch 027: „Snakestone: reduced tracking speed (it is still greater than before the last change)“ + + Übersetzt in die Testszene: + + | Patchnote | Größe | Feld | + | --- | --- | --- | + | preattack duration | Wind-up | `windup` | + | recovery time | Erholung | `recovery` | + | longer to attack when you first enter | erste Pause | `wait_frames` | + | tracking | Zielverfolgung | fehlt | + + Längere Vorbereitung heißt mehr Zeit zum Lesen. Längere Erholung heißt ein breiteres Punish-Fenster. Zielverfolgung, also wie stark ein Angriff dem Spieler nachdreht, kennt der Trainingsgegner nicht. Dafür bräuchte er ein neues Feld. Naughty Dog führt „Target tracking“ als eigenes Ereignis in den Angriffsdaten. Die Snakestone-Zeilen zeigen außerdem, wie Tuning läuft: ein Schritt hoch, ein kleinerer zurück, weiterhin über dem Ausgangswert. + + > [!INTERVIEW] Designtest: „Drei Gegner treffen gleichzeitig, das fühlt sich unfair an“ + > Gute Antwort: erst messen, wie oft sich Angriffe überlagern und ob jeder davon einen sichtbaren Tell hatte. Dann trennen: Fehlt eine Absprache, wer angreifen darf, oder fehlt ein Signal für Angriffe außerhalb des Bildes? Für das erste nennst du Tokens oder eine Obergrenze wie `simultaneousAttackers`, für das zweite die Pfeile aus God of War. Erst danach wählst du eine Größe, änderst sie und testest wieder. Ein fester Wert ohne Test ist keine Antwort. + + ## Häufige Fehler + + - **Zufällige Reihenfolge mit uneinheitlichen Ankündigungen.** Dann lässt sich weder die Reihenfolge noch der einzelne Angriff lernen. Zufall in der Reihenfolge bleibt lesbar, solange jeder Angriff seinen eigenen, gleichbleibenden Tell hat. + - **Ein Tell für verschiedene Angriffe.** Sieht die Ankündigung gleich aus, kann der Spieler nicht wählen, wie er reagiert. + - **Überlagerungen nicht testen.** Greifen Gegner ohne Absprache an, treffen ihre Angriffe irgendwann im selben Frame. Ob das zum Spiel gehört, wie im „dangerous“-Stil, oder ob ein Token-System es verhindert, entscheidest du; testen solltest du es mit mehreren Gegnern gleichzeitig. + - **Signale, die etwas anderes bedeuten.** Blinkende Bildschirmränder hielten Tester bei God of War für Schaden. + - **Gegner, die ins Bild springen.** Ein Gegner, der außerhalb des Bildes aggressiv wird und plötzlich vor dem Spieler steht, zerstört seine Vorstellung vom Kampf. + - **Zielverfolgung bis zum Treffer.** Dreht ein Angriff bis in die aktive Phase nach, nützt frühes Ausweichen wenig. Für den Endboss von Hades nahm Supergiant die Zielverfolgung eines Angriffs zurück („reduced tracking of homing attack“). + - **Tell und Erholung zugleich ändern.** Dann weißt du nicht, ob der Gegner leichter wurde, weil man ihn besser liest oder weil er länger offen steht. + + ## Zusammenfassung + + - Eine Rolle ist eine Frage an den Spieler, und jede Frage hängt an einer messbaren Größe. + - God of War vergibt Aggressions-Tokens aus einem festen Vorrat; wer keine bekommt, wartet hinten wie im Kung-Fu-Film. + - DOOM (2016) vergibt Tokens laut Analyse je Angriffsart; Battle Circle begrenzt die gleichzeitigen Angreifer. + - Telegraph-Kanäle beantworten verschiedene Fragen: Pose wer und wann, Blinken jetzt, Bodenmarkierung wo, Ton auch ohne Blick. + - Längere und deutlichere Tells machen Reaktion erst möglich (The Last of Us Part II). + - Muster sind lernbar, Zufall nicht. Variation wechselt die Reihenfolge, nicht den Tell. + - Bedrohungen außerhalb des Bildes brauchen eindeutige Signale und Gegner, die nicht ins Bild springen. + - Echte Patchnotes zeigen Tuning in Größen: Vorbereitung, Erholung, erste Pause, Zielverfolgung. +exercises: + - id: aggression-tokens + type: choice + title: Wer darf angreifen? + prompt: | + Welche Aussagen zum Aggressions-System von God of War (2018) stimmen laut dem GDC-Vortrag von Mihir Sheth? + options: + - text: Die Gegner werden nach einem Aggressions-Wert sortiert und bekommen Tokens aus einem festen Vorrat. + correct: true + why: Der Vorrat wird der Reihe nach verteilt. Ist er leer, sind die übrigen Gegner nicht aggressiv. + - text: Ein Gegnertyp kann mehrere Tokens belegen, um Größe oder Bedeutung zu zeigen. + correct: true + why: So steht es in den Notizen, „to signify size or importance“. + - text: Ein aggressiver Gegner außerhalb des Bildes läuft sofort ins Bild, damit der Spieler ihn sieht. + correct: false + why: Das Gegenteil. Er soll nicht ins Bild gezwungen werden, bis der Spieler hinsieht, und bleibt in seinem Quadranten. + - text: Die Token-Werte sind für alle Schwierigkeitsgrade gleich. + correct: false + why: Die Werte ändern sich je Schwierigkeit, um die Aggression insgesamt zu erhöhen oder zu senken. + explanation: | + Das System trennt zwei Fragen: Wer ist gerade gefährlich, und wo stehen die anderen? Tokens begrenzen den Druck, den der Spieler gleichzeitig aushalten muss, und die Größe eines Gegners steckt in der Zahl seiner Tokens. Der Schwierigkeitsgrad dreht an denselben Zahlen. Die Positionsregeln für Gegner außerhalb des Bildes gehören dazu, damit der Spieler weiß, wo die wartenden Gegner stehen. + resources: + - title: Evolving Combat in God of War for a New Perspective (Folien mit Notizen) + url: https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf + + - id: kanaele-fragen + type: choice + title: Welcher Kanal sagt was? + prompt: | + Welche Aussagen über Telegraph-Kanäle stimmen? + options: + - text: Eine Bodenmarkierung zeigt Ort und Größe der Fläche, die getroffen wird. + correct: true + why: Das kann keine Pose so genau. Deshalb nutzt die Testszene eine gelbe Fläche. + - text: Ein Ton kann einen Angriff ankündigen, den die Kamera gerade nicht zeigt. + correct: true + why: Ein Geräusch braucht keinen Blickkontakt. Für Bedrohungen außerhalb des Bildes ist das ein eigener Kanal. + - text: Blinkende Bildschirmränder zeigen eindeutig, dass außerhalb des Bildes ein Gegner angreift. + correct: false + why: Tester von God of War hielten sie für Schadensanzeigen. Das Team ersetzte sie durch Pfeile um Kratos. + - text: Je kürzer ein Telegraph, desto besser lässt er sich lesen. + correct: false + why: Naughty Dog hat die Tells für The Last of Us Part II verlängert, damit Spieler ausweichen können. + explanation: | + Jeder Kanal beantwortet eine Frage: Pose wer und wann, Blinken jetzt, Bodenmarkierung wo und wie groß, Ton auch ohne Blick. Ein Signal muss außerdem eindeutig sein. Ein Bildschirmrand, der beim Treffer und bei einem Angriff von hinten gleich blinkt, sagt beides nicht. + resources: + - title: Melee AI in The Last of Us Part II (GDC 2021, Folien) + url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf + + - id: gegner-zyklus + type: order + title: Ein Zyklus des Trainingsgegners + prompt: | + Bring die Zustände des Trainingsgegners in die Reihenfolge, in der sie für Angriff A ablaufen, beginnend mit der Pause vor A. + lang: text + lines: | + WAIT: Pause vor Angriff A + TELEGRAPH: gelbe Fläche sichtbar + ACTIVE: Hitbox an, Fläche aus + RECOVERY: Hitbox aus, Gegner offen + pattern_index rückt weiter + WAIT: Pause vor Angriff B + explanation: | + Die Fläche ist genau im Zustand `TELEGRAPH` sichtbar, die Hitbox genau in `ACTIVE`. Beim Verlassen von `RECOVERY` rückt `pattern_index` eins weiter, deshalb gilt die nächste Pause schon für den nächsten Angriff der Liste. Am Ende der Liste fängt das Muster mit dem Rest der Division wieder bei null an. + resources: + - title: Godot Docs · Arrays (GDScript) + url: https://docs.godotengine.org/en/stable/classes/class_array.html + + - id: token-vorrat + type: output + title: Der Vorrat ist leer + prompt: | + Ein Token-System wie in God of War, Zahlen erfunden. Die Gegner stehen schon nach ihrem Aggressions-Wert sortiert. Wie viele sind aggressiv? Antworte mit der Zahl. + lang: text + code: | + Vorrat: 14 Tokens + Gegner A belegt 6 + Gegner B belegt 5 + Gegner C belegt 3 + Gegner D belegt 2 + expected: | + 3 + accept: ["3 Gegner", "drei"] + explanation: | + A, B und C belegen 6 + 5 + 3 = 14 Tokens. Der Vorrat ist leer, D bekommt keins und wartet hinten. Dass D am wenigsten Tokens bräuchte, hilft ihm nicht: Verteilt wird nach dem Aggressions-Wert, nicht nach Größe. Die 14 stammen aus dem Beispiel der GDC-Folien, die Zahlen je Gegner sind erfunden. + resources: + - title: Evolving Combat in God of War for a New Perspective (Folien mit Notizen) + url: https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf + + - id: naechster-telegraph + type: output + title: Wann kommt der nächste Telegraph? + prompt: | + Muster aus der Lektion: A (Wind-up 20, aktiv 4, Erholung 30), dann B (Wind-up 30, aktiv 6, Erholung 20), Pause jeweils 60. In Frame 290 beginnt A von vorn. In welchem Frame steht das nächste `WAIT -> TELEGRAPH`? Antworte mit der Zahl. + lang: text + code: | + 290 Dummy WAIT -> TELEGRAPH + 310 Dummy TELEGRAPH -> ACTIVE + ??? Dummy ACTIVE -> RECOVERY + ??? Dummy RECOVERY -> WAIT + ??? Dummy WAIT -> TELEGRAPH + expected: | + 404 + accept: ["Frame 404"] + hints: + - Erst die aktive Phase und die Erholung von A, dann die Pause. + explanation: | + A ist ab 310 aktiv, 4 Frames, also `RECOVERY` ab 314. Nach 30 Frames Erholung kommt `WAIT` in 344, nach 60 Frames Pause der Telegraph von B in 404. Das sind 290 + 20 + 4 + 30 + 60. Im gemessenen Lauf steht genau diese Zeile. Ohne Treffer gibt es keinen Hitstop, deshalb gehen die Zahlen glatt auf. + + - id: flaeche-in-ms + type: output + title: Wie lange ist die Fläche zu sehen? + prompt: | + Lies aus dem Log ab, wie lange die gelbe Fläche von Angriff B sichtbar ist, und rechne in Millisekunden um. Antworte mit der Zahl. + lang: text + code: | + 174 Dummy WAIT -> TELEGRAPH + 204 Dummy TELEGRAPH -> ACTIVE + expected: | + 500 + accept: ["500 ms"] + explanation: | + Die Fläche ist in den Frames 174 bis 203 sichtbar, 30 Frames. ms = Frames × 1000 / 60, also 500 ms. Ob das lang genug ist, beantwortet die Rechnung nicht. Sie sagt dir nur, worüber du sprichst, wenn ein Tester „zu kurz“ sagt. + + - id: muster-code + type: gap + title: Muster und Fläche im Code + prompt: | + Ergänze `_enter()` des Trainingsgegners: Nach der Erholung kommt der nächste Angriff der Liste dran, am Ende wieder der erste. Die gelbe Fläche ist genau im Telegraph sichtbar. + lang: gdscript + code: | + if state == State.RECOVERY: + pattern_index = (pattern_index + 1) % ⟦attacks.size()⟧ + state = new_state + frame = 1 + telegraph.visible = ⟦new_state == State.TELEGRAPH⟧ + gaps: + - label: Anzahl der Angriffe + hint: Die Methode eines `Array`, die seine Länge liefert. + accept: ["len(attacks)"] + - label: Wann ist die Fläche sichtbar? + hint: Vergleich des neuen Zustands. + accept: ["State.TELEGRAPH == new_state", "(new_state == State.TELEGRAPH)"] + explanation: | + Der Rest der Division hält `pattern_index` zwischen 0 und der Zahl der Angriffe minus eins: Nach dem letzten Angriff kommt wieder der erste. Die Sichtbarkeit hängt direkt am Zustand, deshalb kann die Fläche nicht versehentlich stehen bleiben. Da jeder Wechsel durch `_enter()` läuft, ändern sich Muster und Fläche nur an dieser einen Stelle. + resources: + - title: Godot Docs · Array.size() + url: https://docs.godotengine.org/en/stable/classes/class_array.html#class-array-method-size + + - id: patchnote-zu-feld + type: gap + title: Patchnotes in Feldnamen + prompt: | + Drei Änderungen aus den Hades-Patchnotes: „increased preattack duration“, „increased recovery time“ und „take slightly longer to attack when you first enter“. Ordne jeder das Feld der Testszene zu, das du dafür ändern würdest. Trag den Feldnamen ein, so wie er im Code steht. + lang: text + code: | + Vorbereitung -> ⟦windup⟧ + Erholung -> ⟦recovery⟧ + Pause beim Betreten -> ⟦wait_frames⟧ + gaps: + - label: Vorbereitung vor dem Angriff + hint: Feld in `AttackData`, beim Gegner gleich der Dauer der gelben Fläche. + - label: Zeit nach dem Angriff + hint: Feld in `AttackData`. + - label: Pause vor dem Angriff + hint: Feld des Gegners, nicht des Angriffs. + explanation: | + `windup` ist beim Trainingsgegner zugleich die Dauer der gelben Fläche. `recovery` bestimmt, wie lange er offen steht. Die Pause vor einem Angriff ist `wait_frames`. Supergiant hat sie nur für den ersten Angriff nach dem Betreten verlängert; die Testszene kennt diese Unterscheidung nicht, du bräuchtest dafür ein eigenes Feld. + resources: + - title: Hades · The Big Bad Update Patch Notes + url: https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/ + + - id: zeitleiste-gegner + type: bug + title: Eine Erholung zu kurz + prompt: | + Du schreibst die Zeitleiste von Angriff A auf (Wind-up 20, aktiv 4, Erholung 30, Pause 60). Eine Zeile passt nicht zu den Werten. Tippe sie an und korrigiere sie. + lang: text + code: | + 60 WAIT -> TELEGRAPH + 80 TELEGRAPH -> ACTIVE + 84 ACTIVE -> RECOVERY + 104 RECOVERY -> WAIT + 174 WAIT -> TELEGRAPH + lines: [4] + fix: + - line: 4 + answers: ["114 RECOVERY -> WAIT", "114 RECOVERY -> WAIT"] + explanation: | + Die Erholung beginnt in Frame 84 und dauert 30 Frames, 84 bis 113. In Frame 114 steht `RECOVERY -> WAIT`. 104 wären 20 Frames, die Erholung von B. Die letzte Zeile ist richtig: 114 + 60 = 174, so steht es im gemessenen Log. + + - id: flaeche-zu-spaet + type: bug + title: Die Fläche kommt mit dem Treffer + prompt: | + Nach einer Änderung erscheint die gelbe Fläche erst, wenn der Angriff schon trifft, und verschwindet nach 4 Frames wieder. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _enter(new_state: State) -> void: + state = new_state + frame = 1 + telegraph.visible = new_state == State.ACTIVE + lines: [4] + fix: + - line: 4 + answers: ["telegraph.visible = new_state == State.TELEGRAPH", "telegraph.visible = State.TELEGRAPH == new_state"] + explanation: | + Die Fläche hängt hier am falschen Zustand. Sie ist nur sichtbar, solange die Hitbox an ist, also genau dann, wenn es zu spät ist. Ein Telegraph muss vor der aktiven Phase kommen. Mit `State.TELEGRAPH` ist die Fläche so lange zu sehen wie `windup` des aktuellen Angriffs. + resources: + - title: Godot Docs · CanvasItem.visible + url: https://docs.godotengine.org/en/stable/classes/class_canvasitem.html#class-canvasitem-property-visible + + - id: pfeile-statt-raender + type: explain + title: Pfeile statt blinkender Ränder + prompt: | + Erkläre in wenigen Sätzen, warum God of War blinkende Bildschirmränder durch Pfeile um Kratos ersetzt hat und warum Gegner außerhalb des Bildes nicht ins Bild gezwungen werden. + lang: text + code: | + rot Angriff kommt + weiß Gegner wartet in der Nähe + lila Fernkampf + points: + - Tester hielten die blinkenden Ränder für Schadensanzeigen; das Signal war nicht eindeutig. + - Pfeile um Kratos zeigen Richtung und Art der Bedrohung, mit Farben für Angriff, Warten und Fernkampf. + - Die weißen Pfeile sind bewusst dezent, damit sie Spieler nicht ablenken, die sie nicht nutzen. + - Ein aggressiver Gegner bleibt außerhalb des Bildes und in seinem Quadranten, bis der Spieler hinsieht; so behält der Spieler seine Vorstellung vom Kampf. + explanation: | + Ein Signal für eine Bedrohung außerhalb des Bildes muss zwei Dinge sagen: dass etwas kommt und woher. Ein blinkender Rand sagte für Tester „du wurdest getroffen“. Pfeile, die auf den Gegner zeigen, sagen Richtung und mit der Farbe auch die Art. Die Positionsregeln sorgen dafür, dass die Pfeile stabil bleiben: Wer hinten links war, bleibt hinten links, bis der Spieler hinsieht. Laut Sheth half das Spielern, eine Vorstellung vom Kampf aufzubauen und mutiger anzugreifen. + resources: + - title: Evolving Combat in God of War for a New Perspective (Folien mit Notizen) + url: https://media.gdcvault.com/gdc2019/presentations/Sheth_Mihir_EvolvingCombat.pdf + + - id: clip-god-of-war + type: practice + title: "Clip-Analyse: der Kung-Fu-Kreis" + prompt: | + Sieh dir den GDC-Vortrag [Evolving Combat in God of War for a New Perspective](https://www.youtube.com/watch?v=hE5tWF-Ou2k) von Mihir Sheth an, ganz oder in Teilen. Achte auf drei Themen: wer angreifen darf (Aggression und Tokens), wo die anderen Gegner stehen (Positionierung) und wie das Spiel Gegner außerhalb des Bildes anzeigt. Notiere zu jedem Thema: + + - was das Problem war, + - was das Team ausprobiert hat, + - und eine Frage, die sich daraus für ein Spiel in der Draufsicht ergibt. + lang: text + solution: | + Beispiel für die Form, nicht für den Inhalt: + + Thema: Gegner außerhalb des Bildes + Problem: Spieler wissen nicht, wer von hinten kommt. + Versuch: blinkende Ränder, als Schaden gelesen; + dann Pfeile um die Figur. + Frage für die Draufsicht: Welche Gegner können bei + 320 × 180 außerhalb des Bildes stehen, und woran + merkt der Spieler, dass einer angreift? + checklist: + - Zu jedem der drei Themen steht ein Problem, ein Versuch und eine Frage. + - Mindestens eine Notiz nennt etwas, das nicht funktioniert hat. + - Jede Frage ist eine Frage, keine Lösung für dein Spiel. + - Du hast notiert, an welcher Stelle im Vortrag du etwas gesehen hast, mit der Zeit aus deinem Player. + explanation: | + GDC-Vorträge zeigen selten nur das Ergebnis. Die verworfenen Versuche sind oft lehrreicher, weil sie zeigen, wie Spieler ein Signal tatsächlich lesen. Die Fragen für die Draufsicht sind dein Material für eigene Entscheidungen; die Antworten findest du am PC in [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen). + resources: + - title: GDC · Evolving Combat in God of War for a New Perspective (YouTube) + url: https://www.youtube.com/watch?v=hE5tWF-Ou2k + + - id: muster-notiz + type: practice + title: Ein Muster für den Trainingsgegner + prompt: | + Entwirf ein Muster aus drei Angriffen für den neutralen Trainingsgegner der Testszene, als Notiz. Für jeden Angriff notierst du `windup`, `active` und `recovery` als Platzhalter und die Form der gelben Fläche. Schreib **vorher** auf, was ein Spieler nach zwei Durchläufen vorhersagen können soll und woran er jeden Angriff erkennt. Werte gibt dir niemand vor. + lang: text + solution: | + Beispiel für die Form. Die Werte sind willkürlich, + sie sind kein Vorschlag: + + Muster: A, A, B + A windup 20, active 4, recovery 30 + Fläche: Rechteck vor dem Gegner + B windup 30, active 6, recovery 20 + Fläche: Kreis um den Gegner + wait_frames 60 + + Vorhersage nach zwei Durchläufen: + „Nach dem zweiten A kommt der Kreis.“ + Erkennen: Rechteck oder Kreis, Form genügt. + Prüfen am PC: Log mit Frame-Nummern, ein Tester, + der das Muster nicht kennt. + checklist: + - Jeder Angriff hat `windup`, `active`, `recovery` und eine beschriebene Fläche. + - Die Vorhersage stand fest, bevor du die Werte eingetragen hast. + - Jeder Angriff ist an etwas anderem erkennbar als die anderen. + - Du hast notiert, wie du am PC prüfst, ob die Vorhersage stimmt. + explanation: | + Ein Muster ist eine Behauptung darüber, was ein Spieler lernen kann. Wer sie vorher aufschreibt, kann sie im Test prüfen. Die Übung ist Entwurfsarbeit am neutralen Trainingsgegner; die Werte sind deine Platzhalter. Am PC trägst du das Muster als Liste von `AttackData` ein und vergleichst Log und Eindruck in [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen). + resources: + - title: Siliconera · Monster Hunter Stories 2 Monster Behavior + url: https://www.siliconera.com/monster-hunter-stories-2-monster-behavior/ diff --git a/content/combat/modules/godot-start.yaml b/content/combat/modules/godot-start.yaml new file mode 100644 index 0000000..8bec327 --- /dev/null +++ b/content/combat/modules/godot-start.yaml @@ -0,0 +1,502 @@ +id: godot-start +title: "Godot-Start: genug Godot 4 für die Testszene" +level: 1 +minutes: 150 +summary: Godot 4.7 unter Linux installieren, ein Projekt pixelgenau einrichten, eine Figur in alle Richtungen laufen lassen und einen Angriff als Animation bauen, deren Hitbox genau drei Frames lang trifft. +goals: + - Du installierst Godot 4.7 unter CachyOS und prüfst, welche Version läuft. + - Du erklärst Node, Szene und Szenenbaum an der Testszene und legst die Nodes selbst an. + - Du richtest ein Projekt so ein, dass Pixelart scharf bleibt und Figuren ohne sichtbare Subpixel-Bewegung laufen. + - Du lässt eine Figur mit `Input.get_vector()` und `move_and_slide()` analog in alle Richtungen laufen und legst die Aktionen in der Input Map an. + - Du baust einen Angriff mit dem `AnimationPlayer`, dessen Hitbox in festen Physik-Frames an- und ausgeht, und misst das nach. +resources: + - title: Godot · Download für Linux + url: https://godotengine.org/download/linux/ + - title: Godot Docs · Nodes and Scenes + url: https://docs.godotengine.org/en/stable/getting_started/step_by_step/nodes_and_scenes.html + - title: Godot Docs · Multiple resolutions + url: https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html + - title: Godot Docs · 2D movement overview + url: https://docs.godotengine.org/en/stable/tutorials/2d/2d_movement.html + - title: Godot Docs · AnimationMixer + url: https://docs.godotengine.org/en/stable/classes/class_animationmixer.html +lesson: | + > [!NOTE] Am PC + > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. + + In den Unterwegs-Modulen rechnest du mit Frames: acht Frames Wind-up, drei aktiv, vierzehn Erholung. Ob sich das gut anfühlt, merkst du erst, wenn du es spielst. Dafür baust du am PC eine kleine Testszene in der Draufsicht: eine Figur, die analog in alle Richtungen läuft, ein Angriff mit einer Hitbox und später Trainingsgegner. Dieses Modul bringt dir genau so viel Godot bei, wie diese Szene braucht. Den ganzen Editor, Import von Sprites, Export und Profiler behandelt der geplante Godot-Kurs. + + Am Ende des Moduls läuft eine Figur mit Tastatur oder Gamepad durch ein 320 × 180 Pixel großes Bild, und ein Tastendruck spielt einen Angriff ab, dessen Hitbox in den Frames 9 bis 11 aktiv ist. Den Code darf dir eine KI schreiben, die Werte bestimmst du. Die Startwerte hier sind Platzhalter, damit überhaupt etwas läuft. + + ## Godot 4.7 unter CachyOS installieren + + Aktuell ist Godot 4.7.2, erschienen am 18. August 2026 ([Release auf GitHub](https://github.com/godotengine/godot/releases/tag/4.7.2-stable)). Für GDScript reicht die normale Version, die .NET-Variante brauchst du nur für C#. Unter Linux hast du drei Wege: + + | Weg | Befehl oder Ort | Was du wissen musst | + | --- | --- | --- | + | Offizielles Binary | [godotengine.org/download/linux](https://godotengine.org/download/linux/) | eine Datei, kein Installieren, Version genau bekannt | + | Paket der Distribution | `sudo pacman -S godot` | Update mit dem System; im Oktober 2026 ebenfalls 4.7.2 ([Arch-Paket](https://archlinux.org/packages/extra/x86_64/godot/)) | + | Flatpak | `flatpak install flathub org.godotengine.Godot` | Sandbox; Community-Paket, nicht vom Godot-Projekt geprüft | + + Für die Testszene spricht viel für das offizielle Binary: Du weißt immer, welche Version du testest, und kannst zwei Versionen nebeneinander behalten. Lade die ZIP-Datei und `SHA512-SUMS.txt` von der Release-Seite, prüfe die Prüfsumme und entpacke: + + ```console + $ sha512sum -c --ignore-missing SHA512-SUMS.txt + Godot_v4.7.2-stable_linux.x86_64.zip: OK + $ unzip Godot_v4.7.2-stable_linux.x86_64.zip + $ ./Godot_v4.7.2-stable_linux.x86_64 --version + 4.7.2.stable.official.ed1daf0bf + ``` + + Ohne Argument startet die Datei den Projektmanager. Leg dort ein neues Projekt `kampf-testszene` an. Als Renderer empfiehlt die Doku für 2D-Spiele **Compatibility** ([Overview of renderers](https://docs.godotengine.org/en/stable/tutorials/rendering/renderers.html#choosing-a-renderer)); du kannst ihn später wechseln. Godot speichert das Projekt als normalen Ordner mit Textdateien, also kannst du ihn mit Git versionieren (siehe [Git-Bereich](/git)). + + ## Node und Szene: der Baum der Testszene + + Alles in Godot ist ein **Node**. Ein Node hat eine Aufgabe: `CharacterBody2D` bewegt sich und stößt an Wände, `Area2D` meldet Überlappungen, `CollisionShape2D` gibt einer der beiden eine Form, `AnimationPlayer` spielt Animationen ab. Nodes hängen in einem Baum. Ein gespeicherter Baum ist eine **Szene** (`.tscn`), und eine Szene kannst du in eine andere einsetzen (instanziieren), etwa denselben Gegner fünfmal ([Nodes and Scenes](https://docs.godotengine.org/en/stable/getting_started/step_by_step/nodes_and_scenes.html), [Creating instances](https://docs.godotengine.org/en/stable/getting_started/step_by_step/instancing.html)). + + Die Startszene sieht so aus: + + ```text + Main (Node2D) + └── Player (CharacterBody2D) player_start.gd + ├── Body (Polygon2D) Quadrat 12 × 12 + ├── CollisionShape2D Körper, 10 × 10 + ├── HitboxPivot (Node2D) dreht sich in Laufrichtung + │ └── Hitbox (Area2D) + │ └── CollisionShape2D 14 × 10, 12 px vor der Figur + └── AnimationPlayer Animation „attack“ + ``` + + `HitboxPivot` ist ein leerer `Node2D` in der Mitte der Figur. Die Hitbox sitzt 12 Pixel rechts davon. Drehst du den Pivot, wandert die Hitbox um die Figur herum. So zeigt der Angriff bei 360°-Bewegung immer in Laufrichtung, ohne dass du acht Hitboxen brauchst. + + Neue Nodes legst du im Szenen-Dock mit „+“ an, Skripte mit dem Schriftrollen-Symbol. Das Inspektor-Dock rechts zeigt die Eigenschaften des gewählten Nodes; dort stellst du auch `motion_mode` der Figur auf **Floating**. Der Modus Grounded kennt Boden, Wand und Decke für Plattformer, Floating behandelt jede Kollision als Wand, wie es eine Draufsicht braucht ([CharacterBody2D](https://docs.godotengine.org/en/stable/classes/class_characterbody2d.html#class-characterbody2d-property-motion-mode)). + + ## Pixel-perfect: scharfe Pixel ohne Subpixel-Bewegung + + Pixelart braucht zwei Dinge. Die Pixel bleiben scharf, wenn das Bild hochskaliert wird. Und eine Figur bewegt sich sichtbar nur in ganzen Pixeln, auch wenn sie intern 1,5 Pixel pro Frame läuft. Beides stellst du unter *Projekt → Projekteinstellungen* ein (Schalter *Erweiterte Einstellungen* oben rechts an). In `project.godot` steht danach: + + ```ini title=project.godot + [display] + window/size/viewport_width=320 + window/size/viewport_height=180 + window/size/window_width_override=1280 + window/size/window_height_override=720 + window/stretch/mode="viewport" + window/stretch/scale_mode="integer" + + [rendering] + textures/canvas_textures/default_texture_filter=0 + 2d/snap/snap_2d_transforms_to_pixel=true + ``` + + Neue Projekte starten in Godot 4.7 mit Stretch Mode `canvas_items` ([Upgrading to Godot 4.7](https://docs.godotengine.org/en/stable/tutorials/migrating/upgrading_to_godot_4.7.html)). Du musst `viewport` also ausdrücklich einstellen. Was die Zeilen tun: + + - **320 × 180** ist die Auflösung, in der das Spiel rechnet und zeichnet. Das Fenster startet mit 1280 × 720, also vierfach. Die Doku schlägt für Pixelart 640 × 360 vor ([Multiple resolutions, Desktop game](https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html#desktop-game)). Die 320 × 180 der Testszene sind ein Platzhalter; welche Auflösung deine Grafik hat, entscheidest du mit deiner Pixelart. + - **Stretch Mode `viewport`** zeichnet erst das kleine Bild und skaliert es dann als Ganzes hoch. Mit `canvas_items` würde Godot in Fenstergröße zeichnen, und eine Figur könnte sich um Viertelpixel verschieben. Genau das willst du nicht ([Multiple resolutions](https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html)). + - **Scale Mode `integer`** skaliert nur um ganze Faktoren. Bei Faktor 3,5 wären manche Pixel drei, andere vier Bildschirmpixel breit. + - **Default Texture Filter `Nearest`** (Wert `0`) holt beim Vergrößern das nächste Pixel statt zu mischen. Mit `Linear`, dem Standard, wird Pixelart unscharf. + - **Snap 2D Transforms to Pixel** rundet beim Zeichnen die Position jedes Nodes auf ganze Pixel. Die Position selbst bleibt eine Kommazahl. + + > [!MERKE] + > Die Figur rechnet in Kommazahlen und wird in ganzen Pixeln gezeichnet. So bleibt die analoge Bewegung genau, und auf dem Bildschirm springt sie trotzdem nur pixelweise. + + Eine Folge davon siehst du bei schrägen Richtungen: Läuft die Figur mit 90 Pixeln pro Sekunde im Winkel von 30°, kommt sie pro Frame 1,3 Pixel nach rechts und 0,75 Pixel nach unten. Gezeichnet springt sie mal einen, mal zwei Pixel nach rechts und in drei von vier Frames einen nach unten. Diese Treppe gehört zu Pixelart in 360°. Beurteile sie in Bewegung, nicht im Standbild. + + Läuft dein Monitor mit mehr als 60 Hz, zeichnet Godot öfter, als die Physik rechnet, und die Figur steht in manchen Bildern still. Dagegen hilft die Physik-Interpolation (`physics/common/physics_interpolation`), die Godot seit 4.3 auch in 2D kann ([Physics interpolation](https://docs.godotengine.org/en/stable/tutorials/physics/interpolation/index.html)). Sie zeichnet Zwischenstände zwischen zwei Physik-Frames und rundet erst dann auf ganze Pixel. Laut Doku erhöht sie die Eingabeverzögerung etwas ([Fixing jitter, stutter and input lag](https://docs.godotengine.org/en/stable/tutorials/rendering/jitter_stutter.html#jitter)). Für die ersten Tests lässt du sie aus: Dann zeigt jedes Bild den Stand eines Physik-Frames, und du siehst Frame-Daten unverfälscht. + + ## Eine Figur, die in alle Richtungen läuft + + Zuerst die Aktionen. Unter *Projekteinstellungen → Eingabezuordnung* (Input Map) legst du `move_left`, `move_right`, `move_up`, `move_down`, `attack` und `dodge` an und gibst jeder Taste und Gamepad-Eingabe: `move_right` bekommt etwa `D`, die Pfeiltaste rechts und *Linker Stick, X-Achse +*. Der Code fragt nur Aktionen ab, nie Tasten. Jede Aktion hat eine **Deadzone**, Standard 0,2: Stick-Ausschläge darunter zählen als 0 ([InputMap](https://docs.godotengine.org/en/stable/classes/class_inputmap.html#class-inputmap-method-add-action), [Input examples](https://docs.godotengine.org/en/stable/tutorials/inputs/input_examples.html)). + + Dann das Skript der Figur: + + ```gdscript title=player_start.gd + extends CharacterBody2D + + ## Figur aus dem Godot-Start: läuft in alle Richtungen, + ## der Angriff ist eine Animation. + + @export var move_speed := 90.0 ## Pixel pro Sekunde + + @onready var animation_player: AnimationPlayer = $AnimationPlayer + @onready var hitbox_pivot: Node2D = $HitboxPivot + + + func _physics_process(_delta: float) -> void: + if animation_player.is_playing(): + return + if Input.is_action_just_pressed(&"attack"): + print(Engine.get_physics_frames(), " Druck attack") + animation_player.play(&"attack") + animation_player.advance(0) # Animation sofort auf Zeit 0 setzen, nicht erst im nächsten Frame + return + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + if direction != Vector2.ZERO: + hitbox_pivot.rotation = direction.angle() + velocity = direction * move_speed + move_and_slide() + ``` + + Die wichtigen Stellen: + + - `_physics_process` läuft 60-mal pro Sekunde, fest an die Physik gekoppelt, unabhängig von der Bildrate ([Idle and Physics Processing](https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html)). Ein Aufruf ist ein Frame im Sinn dieses Kurses. + - `Input.get_vector()` baut aus vier Aktionen einen Richtungsvektor, höchstens 1 lang. Zwei Tasten schräg ergeben `(0.707107, 0.707107)`, nicht `(1, 1)`. Sonst liefe die Figur schräg 41 % schneller. Beim Stick rechnet die Funktion die Deadzone heraus: Ein halber Ausschlag (0,5) bei Deadzone 0,2 wird zu `(0.5 - 0.2) / 0.8 = 0.375` ([Input.get_vector](https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-get-vector)). + - `velocity` ist in Pixeln pro Sekunde. `move_and_slide()` rechnet den Frame-Anteil selbst aus, deshalb taucht `delta` nicht auf. + - `@export` macht `move_speed` im Inspektor einstellbar. Ab jetzt änderst du Werte dort, nicht im Code. + - Solange die Animation läuft, kehrt die Funktion sofort zurück: Die Figur steht während des Angriffs. Ob sie das in deinem Spiel soll, ist eine Designentscheidung. + + ## Ein Angriff als Animation: Hitbox-Timing im AnimationPlayer + + Der Angriff ist eine Animation, die die Hitbox ein- und ausschaltet. Leg im `AnimationPlayer` eine Animation `attack` an ([Introduction to the animation features](https://docs.godotengine.org/en/stable/tutorials/animation/introduction.html)). Unten im Animations-Editor stehen der Zeitschritt und ein Schalter **Seconds/FPS**. Der Schritt steht anfangs auf 1/30 Sekunde, also auf jedem zweiten Physik-Frame. Schalte auf **FPS** und trag 60 ein: Dann zeigt die Zeitleiste Frames, und jeder Key liegt genau auf einem Physik-Frame. Für Wind-up 8, aktiv 3 und Erholung 14 brauchst du: + + | Key | Zeit | nach Frames | `disabled` | + | --- | --- | --- | --- | + | 1 | 0,0000 s | 0 | `true` | + | 2 | 0,1333 s | 8 | `false` | + | 3 | 0,1833 s | 11 | `true` | + | Länge | 0,4167 s | 25 | | + + Die Spalte zählt, wie viele Frames seit dem Start vergangen sind. Der Key bei 8/60 s liegt deshalb am Beginn von Frame 9 des Angriffs, wenn du wie im Rest des Kurses ab 1 zählst. Das ist die häufigste Falle beim Umrechnen. + + Stell `disabled` der Form im Inspektor zuerst auf an, sonst ist die Hitbox bis zum ersten Angriff aktiv. Die Keys setzt du, indem du `Hitbox/CollisionShape2D` wählst und im Inspektor auf das Schlüsselsymbol neben `disabled` klickst. Für Wahrheitswerte legt der Editor eine Spur mit Update-Modus *Discrete* an: Der Wert springt genau am Key um. Eine Spur für die Farbe von `Body` (gelb im Wind-up, rot aktiv, grau in der Erholung) hilft dir später beim Zusehen. + + Eine Einstellung entscheidet, ob das Timing stimmt: **`callback_mode_process`** des `AnimationPlayer`. Standard ist `Idle`. Dann rückt die Animation bei jedem gezeichneten Bild vor, und die Hitbox schaltet irgendwann zwischen zwei Physik-Frames um. Stell sie auf **`Physics`**: Dann rückt die Animation in genau den Schritten vor, in denen die Physik rechnet ([AnimationMixer.callback_mode_process](https://docs.godotengine.org/en/stable/classes/class_animationmixer.html#class-animationmixer-property-callback-mode-process)). + + Gemessen mit Godot 4.7.2: vier Angriffe hintereinander, gezählt wurden Physik-Frames mit eingeschalteter Hitbox. + + | Bildrate | `Physics` | `Idle` | + | --- | --- | --- | + | 60 Bilder/s | 3 je Angriff | 3 je Angriff | + | 144 Bilder/s | 3 je Angriff | 2 je Angriff | + | 30 Bilder/s | 3 je Angriff | 4 je Angriff | + + Im Modus `Idle` hängt die Länge der aktiven Phase an der Bildrate: auf einem 144-Hz-Monitor ein Frame kürzer, auf einem Laptop mit 30 Bildern pro Sekunde einer länger. + + Noch ein Detail im Skript: `advance(0)` direkt nach `play()`. Ohne diese Zeile setzt der `AnimationPlayer` die Animation erst im nächsten Physik-Frame auf ihren Anfang, und alle Keys kommen einen Frame später, als die Tabelle sagt ([AnimationPlayer.play](https://docs.godotengine.org/en/stable/classes/class_animationplayer.html#class-animationplayer-method-play)). Gemessen: ohne `advance(0)` ist die Hitbox in den Frames 10 bis 12 des Angriffs an, mit `advance(0)` in den Frames 9 bis 11. + + > [!WARNING] Umschalten mitten in der Physik + > Schaltest du eine Form oder `monitoring` direkt in einem Treffer-Signal um, meldet Godot `Can't change this state while flushing queries. Use call_deferred() or set_deferred() to change monitoring state instead.` Der Node zeigt danach `disabled = true`, die Physik rechnet aber mit der alten Form weiter. Schreib in Signalen `shape.set_deferred(&"disabled", true)`. Godot führt das am Ende der laufenden Abfrage aus ([Area2D](https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-property-monitoring)). + + ## Messen statt raten: Kollisionsformen, Einzelbild und Frame-Nummern + + Drei Werkzeuge reichen für den Anfang. Im Menü *Debug* schaltest du **Kollisionsformen sichtbar** an; beim nächsten Start zeichnet Godot jede aktive Form farbig ([Overview of debugging tools](https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes)). Im laufenden Spiel hält **Pause** (F9) alles an, und **Nächster Frame** (F10) rückt genau einen Frame vor ([Game embedding](https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10)). So siehst du Bild für Bild, wann die Hitbox erscheint. Und du schreibst in die Ausgabe, in welchem Physik-Frame etwas passiert: + + ```gdscript + func _on_hitbox_area_entered(area: Area2D) -> void: + print(Engine.get_physics_frames(), " Treffer: ", area.name) + ``` + + Verbinde dafür das Signal `area_entered` der Hitbox im Knoten-Dock mit der Figur und stell einen zweiten Node mit `Area2D` und Form in Reichweite. Eine Überlappung, die im Frame X entsteht, meldet Godot im Frame X + 1: Der Physik-Schritt läuft nach deinem Code, das Signal kommt am Anfang des nächsten Frames ([Using Area2D](https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html)). + + > [!PRAXIS] + > Bau dir einen Debug-Schalter für Hitboxen, bevor du irgendetwas abstimmst. Wer über Frames redet, muss sie sehen und zählen können. Bau die Messung ein, bevor du anfängst abzustimmen, nicht erst, wenn sich etwas falsch anfühlt. + + > [!INTERVIEW] + > „Wie stellst du sicher, dass ein Angriff unabhängig von der Bildrate gleich lange trifft?“ Gute Antwort: Gameplay-Timing läuft im festen Physik-Takt, nicht pro gezeichnetem Bild. In Godot heißt das `_physics_process` und `callback_mode_process = Physics` für Animationen, die Hitboxen schalten. Dann misst du es nach, etwa bei künstlich gesenkter Bildrate. + + ## Häufige Fehler + + - **Unscharfe Pixel.** Der Texturfilter steht auf `Linear`. Projektweit auf `Nearest` stellen, nicht an jedem Sprite einzeln. + - **Figur gleitet um Bruchteile von Pixeln.** Stretch Mode `canvas_items` statt `viewport`, oder Snap 2D Transforms to Pixel ist aus. + - **Schräg schneller als gerade.** Der Vektor kommt aus zwei einzelnen Achsen (`Vector2(rechts - links, runter - hoch)`) statt aus `Input.get_vector()`. + - **Hitbox einen Frame zu spät oder zu lang.** Keys liegen nicht auf dem 1/60-Raster (der Standardschritt ist 1/30 s), oder `callback_mode_process` steht auf `Idle`. + - **Fehlermeldung beim Treffer.** Form oder `monitoring` direkt im Signal umgeschaltet. `set_deferred()` nehmen. + - **Nichts wird erkannt.** `collision_mask` der Hitbox enthält die Ebene des Ziels nicht, oder beim Ziel ist `monitorable` aus. + + ## Zusammenfassung + + - Godot 4.7.2 ist die aktuelle stabile Version. Das offizielle Binary läuft ohne Installation; `--version` zeigt, welche du testest. + - Neue Projekte starten in 4.7 mit `canvas_items`; für Pixelart stellst du `viewport` selbst ein. + - Nodes haben je eine Aufgabe, eine Szene ist ein gespeicherter Baum aus Nodes. Ein Pivot-Node dreht die Hitbox in Laufrichtung. + - Pixel-perfect heißt: 320 × 180, Stretch Mode `viewport`, Scale Mode `integer`, Filter `Nearest`, Snap 2D Transforms to Pixel. Positionen bleiben Kommazahlen. + - `Input.get_vector()` liefert einen Vektor bis Länge 1 und rechnet die Deadzone heraus. Der Code fragt Aktionen ab, keine Tasten. + - Ein Frame ist ein Aufruf von `_physics_process`, 60 pro Sekunde. + - Animationen, die Hitboxen schalten, laufen mit `callback_mode_process = Physics` und Keys auf dem 1/60-Raster. + - Treffer kommen einen Frame nach der Überlappung. In Signalen schaltest du mit `set_deferred()` um. +exercises: + - id: nodes-zuordnen + type: choice + title: Welcher Node macht was? + prompt: | + Welche Zuordnungen stimmen für die Testszene? + options: + - text: "`CharacterBody2D` für die Figur, weil sie sich mit `move_and_slide()` bewegt und an Wänden entlanggleitet." + correct: true + why: Genau dafür ist der Node da. Er bringt `velocity` und `move_and_slide()` mit. + - text: "`Area2D` für die Hitbox, weil sie Überlappungen mit anderen Flächen meldet, ohne etwas wegzuschieben." + correct: true + why: Eine Hitbox soll treffen, nicht blockieren. `Area2D` meldet `area_entered`, schiebt aber nichts. + - text: "`CollisionShape2D` erkennt Treffer und sendet das Signal `area_entered`." + correct: false + why: Die Form gibt einem Körper oder einer Fläche nur ihre Gestalt. Das Signal sendet die `Area2D`, zu der sie gehört. + - text: "`AnimationPlayer` braucht eine eigene `CollisionShape2D`, damit er die Hitbox steuern kann." + correct: false + why: Der `AnimationPlayer` setzt Eigenschaften anderer Nodes über Spuren, hier `disabled` der Form der Hitbox. Eine eigene Form hat er nicht. + explanation: | + Jeder Node hat eine Aufgabe. `CharacterBody2D` bewegt sich und kollidiert, `Area2D` erkennt Überlappungen, `CollisionShape2D` liefert die Form für einen dieser beiden, und `AnimationPlayer` ändert über Spuren Eigenschaften anderer Nodes. Der Angriff entsteht erst aus dem Zusammenspiel: Die Animation schaltet die Form der Hitbox ein, die `Area2D` meldet, was sie berührt. + resources: + - title: Godot Docs · Nodes and Scenes + url: https://docs.godotengine.org/en/stable/getting_started/step_by_step/nodes_and_scenes.html + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html + + - id: stretch-modus + type: choice + title: Keine Viertelpixel + prompt: | + Du willst, dass sich Figuren auf dem Bildschirm nur in ganzen Pixeln der 320 × 180 großen Spielgrafik bewegen. Welcher Stretch Mode passt? + options: + - text: "`viewport`: Godot zeichnet das Bild in 320 × 180 und skaliert es danach als Ganzes hoch." + correct: true + why: Weil das kleine Bild zuerst entsteht, kann nichts zwischen zwei seiner Pixel liegen. + - text: "`canvas_items`: Godot zeichnet in Fenstergröße und skaliert nur die Koordinaten." + correct: false + why: Dabei kann eine Figur um Bruchteile eines Spielpixels versetzt erscheinen. Gut für scharfe Schrift, schlecht für das Ziel hier. + - text: "`disabled`: Godot skaliert gar nicht, das Fenster ist 320 × 180 groß." + correct: false + why: Dann ist das Fenster winzig. Das Problem verschwindet, das Spiel aber auch. + explanation: | + Im Modus `viewport` rechnet und zeichnet Godot in der eingestellten Größe und vergrößert das fertige Bild. Zusammen mit Scale Mode `integer`, Filter `Nearest` und dem Pixel-Snap bleibt jedes Spielpixel ein gleich großes Quadrat. `canvas_items` ist der richtige Modus für Spiele mit hochaufgelöster Grafik und Schrift, bei denen weiche Bewegung erwünscht ist. + resources: + - title: Godot Docs · Multiple resolutions + url: https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html + + - id: projekt-einrichten + type: order + title: Ein Projekt für Pixelart einrichten + prompt: | + Bring die Schritte in eine sinnvolle Reihenfolge, vom Download bis zur ersten Probe der Bewegung. + lang: text + lines: | + Prüfsumme der ZIP-Datei mit sha512sum kontrollieren + Godot starten und im Projektmanager ein neues Projekt anlegen + Viewport 320 × 180, Stretch Mode viewport, Scale Mode integer einstellen + Default Texture Filter auf Nearest und Snap 2D Transforms to Pixel einschalten + Aktionen move_left, move_right, move_up, move_down in der Input Map anlegen + Player mit Skript anlegen und die Szene mit F6 starten + alternatives: + - [1, 2, 4, 3, 5, 6] + - [1, 2, 3, 5, 4, 6] + - [1, 2, 4, 5, 3, 6] + - [1, 2, 5, 3, 4, 6] + - [1, 2, 5, 4, 3, 6] + explanation: | + Die Prüfsumme kommt vor dem ersten Start, sonst führst du eine Datei aus, die du nicht geprüft hast. Die Projekteinstellungen und die Input Map sind voneinander unabhängig, ihre Reihenfolge ist egal. Alle drei müssen aber vor dem ersten Test stehen: Ohne Aktionen liefert `Input.get_vector()` immer `(0, 0)`, ohne die Pixel-Einstellungen beurteilst du eine Bewegung, die später anders aussieht. + resources: + - title: Godot Docs · Input examples + url: https://docs.godotengine.org/en/stable/tutorials/inputs/input_examples.html + + - id: schraeg-laufen + type: output + title: Zwei Tasten gleichzeitig + prompt: | + Die Aktionen `move_right` und `move_down` sind beide voll gedrückt (zwei Tasten). Was gibt diese Zeile in Godot 4.7 aus? + lang: gdscript + code: | + print(Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down")) + expected: | + (0.707107, 0.707107) + accept: + - (0.70710677, 0.70710677) + - (0.7071, 0.7071) + explanation: | + `get_vector()` begrenzt den Vektor auf Länge 1. Aus `(1, 1)` mit Länge 1,414 wird `(0.707107, 0.707107)`; `print` zeigt Vektoren mit sechs signifikanten Stellen. Ohne diese Begrenzung liefe die Figur schräg um gut 41 % schneller als gerade, ein Fehler, der in vielen ersten Prototypen steckt. Gemessen mit Godot 4.7.2. + resources: + - title: Godot Docs · Input.get_vector + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-get-vector + + - id: deadzone-rechnen + type: output + title: Der halbe Stick + prompt: | + Der linke Stick ist halb nach rechts gedrückt (Stärke 0,5), sonst nichts. Die Aktionen haben die Standard-Deadzone 0,2. Was gibt die Zeile aus? + lang: gdscript + code: | + print(Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down")) + expected: | + (0.375, 0.0) + accept: + - (0.375, 0) + explanation: | + `get_vector()` rechnet die Deadzone heraus und streckt den Rest auf 0 bis 1: `(0,5 − 0,2) / (1 − 0,2) = 0,375`. Unterhalb von 0,2 kommt `(0.0, 0.0)` heraus. So startet die Figur am Rand der Deadzone mit Tempo 0 statt mit einem Sprung auf 20 %. Gemessen mit Godot 4.7.2. + resources: + - title: Godot Docs · Input.get_vector + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-get-vector + + - id: keys-in-frames + type: output + title: Sekunden in Frames + prompt: | + Eine Animation `attack` hat diese Keys für `disabled` der Hitbox. Die Physik läuft mit 60 Frames pro Sekunde. Wie viele Physik-Frames ist die Hitbox an? + lang: text + code: | + 0,0000 s disabled = true + 0,1000 s disabled = false + 0,2000 s disabled = true + expected: | + 6 + accept: + - 6 Frames + explanation: | + 0,1 s sind 6 Frames, 0,2 s sind 12. Die Hitbox geht nach 6 Frames an und nach 12 Frames aus, ist also 6 Frames lang; ab 1 gezählt sind das die Frames 7 bis 12. Rechne Animationszeiten immer in Frames um, bevor du über Timing redest: Ein Key bei 0,1 s klingt rund, liegt aber nur zufällig auf dem 1/60-Raster. 0,15 s wären 9 Frames, 0,11 s wären 6,6 und damit kein ganzer Frame. + + - id: bewegung-ergaenzen + type: gap + title: Die Figur zum Laufen bringen + prompt: | + Ergänze die drei Lücken, damit die Figur analog in alle Richtungen läuft. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + var direction := ⟦Input.get_vector⟧(&"move_left", &"move_right", &"move_up", &"move_down") + ⟦velocity⟧ = direction * move_speed + ⟦move_and_slide()⟧ + gaps: + - label: Richtung aus vier Aktionen + hint: Eine Methode von `Input`, die einen `Vector2` aus vier Aktionen baut. + - label: Eigenschaft für die Geschwindigkeit + hint: Die Eigenschaft von `CharacterBody2D`, in Pixeln pro Sekunde. + - label: Bewegung ausführen + hint: Bewegt die Figur mit `velocity` und gleitet an Wänden entlang. + accept: ["self.move_and_slide()"] + explanation: | + `Input.get_vector()` liefert die Richtung mit Länge bis 1. `velocity` von `CharacterBody2D` ist in Pixeln pro Sekunde, und `move_and_slide()` multipliziert intern mit der Dauer eines Physik-Frames. Deshalb braucht der Code kein `delta`. Würdest du `position += direction * move_speed` schreiben, liefe die Figur 60-mal zu schnell und durch Wände. + resources: + - title: Godot Docs · 2D movement overview + url: https://docs.godotengine.org/en/stable/tutorials/2d/2d_movement.html + - title: Godot Docs · CharacterBody2D.move_and_slide + url: https://docs.godotengine.org/en/stable/classes/class_characterbody2d.html#class-characterbody2d-method-move-and-slide + + - id: animation-idle + type: bug + title: Auf dem Laptop trifft es länger + prompt: | + Auf deinem PC ist die Hitbox des Angriffs 3 Frames aktiv. Auf einem alten Laptop mit 30 Bildern pro Sekunde zählst du 4 Frames. Die Keys liegen sauber auf dem 1/60-Raster. Finde die Zeile, die das verursacht, und korrigiere sie. + lang: gdscript + code: | + func _ready() -> void: + animation_player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_IDLE + hitbox_shape.disabled = true + lines: [2] + fix: + - line: 2 + answers: + - animation_player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS + - animation_player.callback_mode_process = AnimationPlayer.ANIMATION_CALLBACK_MODE_PROCESS_PHYSICS + explanation: | + Im Modus `Idle` rückt die Animation pro gezeichnetem Bild vor. Bei 30 Bildern pro Sekunde springt sie in Schritten von zwei Physik-Frames, und die Hitbox schaltet zu anderen Zeitpunkten um, als die Keys sagen. Mit `PHYSICS` rückt sie mit jedem Physik-Frame vor. Gemessen mit Godot 4.7.2: im Modus `Idle` bei 30 Bildern pro Sekunde 4 statt 3 Frames, im Modus `Physics` immer 3. + resources: + - title: Godot Docs · AnimationMixer.callback_mode_process + url: https://docs.godotengine.org/en/stable/classes/class_animationmixer.html#class-animationmixer-property-callback-mode-process + + - id: signal-umschalten + type: bug + title: Ausgeschaltet und trifft trotzdem + prompt: | + Nach dem ersten Treffer soll die Hitbox sofort aus sein. Der Inspektor zeigt danach auch `disabled = true`, trotzdem meldet die Hitbox weitere Treffer, und die Ausgabe zeigt `Can't change this state while flushing queries. Use call_deferred() or set_deferred() to change monitoring state instead.` Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _on_hitbox_area_entered(area: Area2D) -> void: + print(Engine.get_physics_frames(), " Treffer: ", area.name) + hitbox_shape.disabled = true + lines: [3] + fix: + - line: 3 + answers: + - hitbox_shape.set_deferred(&"disabled", true) + - hitbox_shape.set_deferred("disabled", true) + - hitbox_shape.call_deferred(&"set_disabled", true) + - hitbox_shape.call_deferred("set_disabled", true) + explanation: | + Das Signal kommt, während der Physik-Server seine Abfragen abarbeitet. In diesem Moment lehnt er Änderungen an Formen ab. Die Eigenschaft des Nodes ändert sich, der Server rechnet aber mit der alten Form weiter, daher der Widerspruch im Inspektor. `set_deferred()` merkt die Änderung vor und führt sie aus, wenn die Abfrage fertig ist. Die Meldung ist mit Godot 4.7.2 so nachgestellt. + resources: + - title: Godot Docs · Object.set_deferred + url: https://docs.godotengine.org/en/stable/classes/class_object.html#class-object-method-set-deferred + + - id: startfigur-erklaeren + type: explain + title: Warum steht die Figur beim Angriff? + prompt: | + Erkläre, was dieser Anfang von `_physics_process` bewirkt und warum er in `_physics_process` steht und nicht in `_process`. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + if animation_player.is_playing(): + return + if Input.is_action_just_pressed(&"attack"): + print(Engine.get_physics_frames(), " Druck attack") + animation_player.play(&"attack") + animation_player.advance(0) + return + points: + - Solange die Angriffsanimation läuft, macht die Figur nichts anderes, sie kann weder laufen noch neu angreifen. + - Ein neuer Druck auf `attack` startet die Animation; der zweite `return` verhindert, dass die Figur im selben Frame noch läuft. + - "`_physics_process` läuft im festen Takt von 60 pro Sekunde, `_process` pro gezeichnetem Bild; nur der feste Takt gibt gleiche Frames auf jedem Rechner." + - Drückst du `attack` während der Animation, geht der Druck verloren; einen Eingabepuffer gibt es hier noch nicht. + explanation: | + Die Animation sperrt die Figur: Während sie läuft, kehrt die Funktion sofort zurück. Ein Druck auf `attack` startet sie und beendet den Frame, damit die Figur nicht im selben Frame noch einen Schritt macht. Das steht in `_physics_process`, weil Gameplay-Timing im festen Physik-Takt laufen muss; `_process` hängt an der Bildrate. Eine Lücke hat der Code noch: `is_action_just_pressed()` ist nur in dem einen Frame wahr, in dem die Taste runtergeht. Wer kurz vor dem Ende des Angriffs drückt, verliert den Druck. Den Eingabepuffer dafür baust du in [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff). + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: godot-installieren + type: practice + title: Godot installieren und das Projekt anlegen + prompt: | + Installiere Godot 4.7 auf deinem Rechner, leg das Projekt `kampf-testszene` an und richte es pixelgenau ein. Notiere die Ausgabe von `--version`. + lang: text + solution: | + $ sha512sum -c --ignore-missing SHA512-SUMS.txt + Godot_v4.7.2-stable_linux.x86_64.zip: OK + $ ./Godot_v4.7.2-stable_linux.x86_64 --version + 4.7.2.stable.official.ed1daf0bf + + project.godot enthält: + window/size/viewport_width=320 + window/size/viewport_height=180 + window/stretch/mode="viewport" + window/stretch/scale_mode="integer" + textures/canvas_textures/default_texture_filter=0 + 2d/snap/snap_2d_transforms_to_pixel=true + checklist: + - Die Prüfsumme der ZIP-Datei ist mit `sha512sum -c` geprüft, oder Godot kommt aus `pacman` oder Flathub. + - "`--version` zeigt 4.7 oder neuer, und du hast die Ausgabe notiert." + - Die Projekteinstellungen stehen wie in der Lektion, geprüft in `project.godot`. + - Der Projektordner liegt in Git, mit einem ersten Commit. + explanation: | + Die Version gehört in jedes Testprotokoll. Ändert sich in einer neuen Godot-Version das Timing, siehst du so, woran es liegt. Die Einstellungen prüfst du in `project.godot`, weil der Editor manche Werte erst schreibt, wenn sie vom Standard abweichen: Was dort nicht steht, ist der Standard. + resources: + - title: Godot · Download für Linux + url: https://godotengine.org/download/linux/ + - title: Godot Docs · Multiple resolutions + url: https://docs.godotengine.org/en/stable/tutorials/rendering/multiple_resolutions.html + + - id: startszene-messen + type: practice + title: Die Startszene bauen und die Hitbox messen + prompt: | + Bau die Startszene aus der Lektion: Figur mit `player_start.gd`, Hitbox am Pivot, Animation `attack`. Stell eine zweite `Area2D` mit Form vor die Figur, verbinde `area_entered` und miss, in welchem Physik-Frame der Treffer kommt. Lass die Figur außerdem langsam schräg laufen und beobachte die Pixeltreppe. + lang: gdscript + starter: | + func _on_hitbox_area_entered(area: Area2D) -> void: + pass + solution: | + func _on_hitbox_area_entered(area: Area2D) -> void: + print(Engine.get_physics_frames(), " Treffer: ", area.name) + + # Ausgabe, wenn die Figur den Druck in Frame 300 sieht + # und das Ziel in Reichweite steht: + # 300 Druck attack + # 309 Treffer: Ziel + checklist: + - Die Figur läuft mit Tastatur und Stick in alle Richtungen, schräg nicht schneller als gerade. + - Mit *Debug → Kollisionsformen sichtbar* siehst du die Hitbox nur während der aktiven Phase. + - "`callback_mode_process` des `AnimationPlayer` steht auf `Physics`, die Zeitleiste zeigt Frames bei 60 FPS." + - Du bist mit Pause (F9) und Nächster Frame (F10) einmal Bild für Bild durch einen Angriff gegangen. + - Du hast notiert, wie viele Frames zwischen Tastendruck und Treffer-Ausgabe liegen, und erklärst die Zahl mit Wind-up und der Meldung einen Frame später. + - Du hast beim langsamen Schräglaufen die Pixeltreppe gesehen und notiert, ob sie dich stört. Das ist deine Beobachtung, keine richtige oder falsche Antwort. + explanation: | + Die Figur schreibt den Druck in dem Frame, in dem sie ihn sieht; das ist Frame 1 des Angriffs. Mit Wind-up 8 geht die Hitbox im neunten Frame an, und das Signal kommt einen Frame später: Zwischen den beiden Ausgaben liegen genau 9 Frames. Fehlt `advance(0)`, sind es 10. Die genaue Zahl ist weniger wichtig als die Gewohnheit: Du misst, bevor du etwas fühlst. Die Pixeltreppe beim Schräglaufen ist keine Fehlfunktion, sondern die Folge von ganzen Pixeln. Ob du sie mit anderer Geschwindigkeit, anderen Winkeln oder gar nicht behandelst, entscheidest du für dein Spiel. + resources: + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html diff --git a/content/combat/modules/grosse-gegner.yaml b/content/combat/modules/grosse-gegner.yaml new file mode 100644 index 0000000..5947bb4 --- /dev/null +++ b/content/combat/modules/grosse-gegner.yaml @@ -0,0 +1,450 @@ +id: grosse-gegner +title: Große Kreaturen als Bosse +level: 3 +minutes: 120 +summary: Was ein großer Gegner in der Draufsicht anders macht – Größe und Lesbarkeit, Hurtbox-Zonen und Schwachpunkte, Phasen, Kamera, Vorhersagbarkeit und Unterbrechbarkeit –, an veröffentlichten Spielen und dem neutralen großen Trainingsgegner der Testszene. +goals: + - Du rechnest aus, ob ein Telegraph reicht, um eine Fläche zu Fuß oder mit dem Ausweichen zu verlassen. + - Du erklärst, wie Hurtbox-Zonen und Schwachpunkte mit der Besiegmethode zusammenhängen, an Hades und Shadow of the Colossus. + - Du beschreibst, wie veröffentlichte Spiele Bosskämpfe in Phasen teilen und was Phasenwechsel kosten. + - Du benennst Kamera-Techniken für große Gegner (zoom-to-fit, cue-focus) und ihre Grenzen bei pixelgenauer Darstellung. + - Du stellst für einen eigenen großen Gegner die Fragen, die du beantworten musst, ohne dass dir jemand die Antworten vorgibt. +resources: + - title: GlitterBerri · The Graphic Designers Tell It Straight (Shadow of the Colossus) + url: https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/ + - title: Game Developer · Game Design Deep Dive – One-hit kills in Titan Souls + url: https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i- + - title: Itay Keren · Scroll Back – The Theory and Practice of Cameras in Side-Scrollers + url: https://www.gamedeveloper.com/design/scroll-back-the-theory-and-practice-of-cameras-in-side-scrollers + - title: GDC 2024 · Refining the Rogues Gallery – Boss Fights in Marvel's Spider-Man 2 + url: https://gdcvault.com/play/1034367/Refining-the-Rogues-Gallery-Building +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Für Shadow of the Colossus gab Fumito Ueda den Grafikern laut ihrem Kollegen Suzuki nur zwei Dinge vor: „the size of the Colossus and the pixel resolution of the screen“ ([GlitterBerri](https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/)). Größe und Auflösung zusammen bestimmen, was der Spieler von einem großen Gegner überhaupt sehen kann. In einem Spiel mit 320 × 180 Pixeln in der Draufsicht ist das die erste Frage bei jeder großen Kreatur. + + Du entwirfst eigene große Kreaturen als Bosse. Dieses Modul entwirft keine davon. Es zeigt, welche Fragen veröffentlichte Spiele an große Gegner gestellt haben, und rechnet am **großen Trainingsgegner** der Testszene: dieselbe Logik wie der Trainingsgegner, aber groß, mit den Hurtbox-Zonen `kopf` und `bein` und einer großen Angriffsfläche. Alle Werte dort sind Platzhalter. Die Antworten für deine Kreaturen findest du selbst. + + ## Größe und Lesbarkeit in der Draufsicht + + Ein großer Gegner deckt einen großen Teil des Bildes ab, und seine Angriffe treffen große Flächen. Daraus folgen drei Fragen: + + - Sieht der Spieler die ganze Fläche, die gleich getroffen wird, oder nur den Teil im Bild? + - Erkennt er am Körper, was als Nächstes kommt, wenn der Körper größer ist als die Figur? + - Reicht die Ankündigung, um aus der Fläche herauszukommen? + + Bei Cuphead kam die Größe früh. Jared Moldenhauer von Studio MDHR sagt, man habe oft zuerst entworfen, wie ein Boss sich spielt: „There's a feeling for the size of the boss or where it is situated on screen“, und erst danach das Konzept gezeichnet ([Kotaku](https://kotaku.com/cuphead-developers-talk-cut-bosses-and-whats-in-the-cup-1820126327)). + + Die dritte Frage lässt sich rechnen. Ein Frame ist ein Physik-Tick, 1/60 Sekunde. Die Figur der Testszene läuft mit `move_speed = 90` Pixeln pro Sekunde, das Ausweichen bewegt sie mit `dodge_speed = 220`, beides Platzhalter. Für einen Weg von N Pixeln braucht sie: + + ```text + Frames = Pixel / (px/s) × 60, aufgerundet + ``` + + Aufgerundet, weil eine Figur, die nach 26 Frames 39 Pixel weit ist, aus einer Fläche mit 40 Pixeln Radius noch nicht heraus ist. Als Modell: + + ```python run + import math + + def frames_raus(weg_px, px_pro_s): + return math.ceil(weg_px * 60 / px_pro_s) + + # Platzhalter der Testszene + print(frames_raus(40, 90)) # laufen + print(frames_raus(40, 220)) # ausweichen + ``` + + 40 Pixel zu Fuß dauern 27 Frames, mit dem Ausweichen 11. Bei einem Telegraph von 20 Frames reicht Laufen also nicht, selbst wenn der Spieler im ersten Frame reagiert. Das Modell lässt bewusst einiges weg: die Reaktionszeit, die Größe der Hurtbox der Figur (ihre halbe Breite kommt zum Weg dazu) und den genauen Frame, in dem Godot die Überlappung prüft. Es sagt nur: Reicht es nicht einmal im besten Fall, kommt niemand zu Fuß heraus. + + Rechne `weg_px * 60` zuerst und teile dann. 93 Pixel bei 90 px/s ergeben so exakt 62,0 Frames. Andersherum, `93 / 90 * 60`, liefert die Gleitkommarechnung 62,00000000000001, und das Aufrunden macht daraus 63. + + > [!MERKE] + > Eine große Fläche ist eine Rechenaufgabe, bevor sie ein Gefühl ist. Weg in Pixeln, Geschwindigkeit, Telegraph in Frames: Wenn die Rechnung nicht aufgeht, hilft kein Tuning am Gefühl. + + ## Zonen und Schwachpunkte: Design folgt der Besiegmethode + + Ein großer Gegner hat oft mehrere Stellen, an denen er getroffen werden kann. In der Testszene ist das eine Hurtbox je Zone, jede ein direktes Kind des Gegners, mit eigenem Namen und eigenem Faktor: + + ```gdscript + @export var zone := &"body" + @export var damage_multiplier := 1.0 + ``` + + Der große Trainingsgegner hat zwei davon, `kopf` und `bein`. Ein Treffer steht im Log mit Zone und Schaden, etwa `getroffen: Zone bein, Schaden 1.0`. Berührt ein Schlag beide Zonen in derselben aktiven Phase, zählt er trotzdem nur einmal: Die Figur merkt sich getroffene Gegner in `already_hit` und erkennt sie am Elternknoten der Hurtbox (siehe [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff)). Welche der beiden Zonen dann zählt, bestimmt die Reihenfolge, in der Godot die Überlappungen meldet; in einem gemessenen Lauf mit einer Hitbox, die beide berührte, war es `bein`, nicht `kopf`. Weil die Zonen verschieden viel Schaden nehmen, ist diese Reihenfolge eine Frage, die du bewusst regelst, wenn sie für dein Spiel zählt. + + Welche Zone wann erreichbar ist und was ein Treffer dort ändert, ist keine technische Frage. Bei Hades beschreibt Combat Designer Eduardo Gorinstein die Hydra so: „The hydra has regenerating heads, but he has the main head.“ Der Mythos, nach dem man erst alle Nebenköpfe abschlägt und dann den Hauptkopf töten kann, habe die Struktur der Kämpfe beeinflusst ([DigiPen](https://digipen.edu/showcase/news/eduardo-gorinstein-delivers-challenge-and-fun-hades)). Die Reihenfolge der Ziele ist dort der Kampf. + + Shadow of the Colossus geht noch weiter. Ueda: „The design of the Colossi is closely related to how you go about defeating them“ ([GlitterBerri](https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/)). Das Team plante zuerst 48 Kolosse, kürzte auf 24 und landete bei 16. Es prüfte, dass sich die Besiegmethoden nicht überschneiden. Eine Technik, die das Team „transforming collision“ nannte, machte Haltung und Timing glaubwürdig, wenn Wander von einem sich bewegenden Koloss stürzt ([GlitterBerri](https://glitterberri.com/extended-developer-interview/making-of-the-colossi/)). + + ### Die Arena hängt am Boss + + Bei Shadow of the Colossus änderte sich mit einem Koloss oft auch sein Ort. Lief ein Test schlecht, änderte das Team laut Niwa den Koloss, „we'd end up having to alter the arena as well“. Die Teams für Gelände und Figuren tauschten sich deshalb ständig aus. Bei Cuphead war für die dritte Phase des Riesen Glumstone von Anfang an ein anderer Untergrund geplant als für die ersten beiden ([Game Informer](https://gameinformer.com/feature/2022/12/27/how-studio-mdhr-builds-a-cuphead-boss)). Titan Souls lässt den Spieler nach einem Tod außerhalb des Bossraums wieder erscheinen, nicht darin ([Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i-)). Auch der Weg zurück gehört zur Arena. + + > [!PRAXIS] Bauen, testen, abstimmen, wieder testen + > Suzuki beschreibt die Arbeit an den Kolossen als „repeating cycle of creating something, testing it, tuning it, and testing it out once more“ ([GlitterBerri](https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/)). Große Gegner sind teuer: Ändert sich die Besiegmethode, ändern sich Zonen, Animation und Arena mit. Halte deshalb früh fest, welche Frage der Kampf stellt, und teste diese Frage mit grauen Kästen, bevor jemand Pixelart zeichnet. + + ## Phasen: den Kampf in Abschnitte teilen + + Viele Bosskämpfe wechseln in Phasen. Eine Phase ist ein Abschnitt mit eigenen Angriffen, eigenem Muster oder eigener Arena. Beispiele aus veröffentlichten Spielen: + + - **Cuphead**: Der Kampf gegen Glumstone the Giant aus The Delicious Last Course hat drei Phasen. Eine Idee mit einem Springseil flog raus, weil „the swinging motion created issues with projectile timing“. Studio MDHR strebt letzte Phasen an, die „neither the longest nor the most difficult“ sind, und der ganze Kampf geht in unter zwei Minuten ([Game Informer](https://gameinformer.com/feature/2022/12/27/how-studio-mdhr-builds-a-cuphead-boss)). + - **Furi**: Wer eine Phase gewinnt, bekommt „a square of life“ zurück ([GOG](https://gog.com/news/gog_interview_the_game_bakers_and_furis_success)). Zwei Bosse sind laut Emeric Thoa gewollte Spitzen, „made to require perseverance“ ([DualShockers](https://www.dualshockers.com/furi-third-seventh-boss-hard/)). + - **Hades**: Patch 025 sagt: „Boss foes switch between phases faster (their invulnerability periods are shorter)“ ([Patchnotes](https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/)). Ein Phasenwechsel mit Unverwundbarkeit kostet Zeit, in der der Spieler nichts tun kann. + - **Titan Souls**: Mark Foster beschreibt den klassischen Zelda-Kampf als Erkennen, Ausführen und zweimaliges Wiederholen. Titan Souls verdichtet das auf einen einzigen Treffer, und das „increased the tension of the encounter dramatically“ ([Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i-)). + + Der Trainingsgegner der Testszene kennt keine Phasen. Er spielt eine Liste `attacks` der Reihe nach ab. Eine Phase wäre eine andere Liste ab einer Bedingung, etwa nach einer Zahl von Treffern. Ob deine Bosse Phasen haben, wann sie wechseln und was der Wechsel kostet, entscheidest du. + + ## Kamera: was im Bild sein muss + + Itay Keren hat Kamera-Techniken aus Side-Scrollern gesammelt und benannt ([Scroll Back](https://www.gamedeveloper.com/design/scroll-back-the-theory-and-practice-of-cameras-in-side-scrollers)). Zwei Begriffe aus seinem Glossar passen zu großen Gegnern: + + - **zoom-to-fit**: „Change zoom or move forward/back to provide a close-up view of relevant elements“. + - **cue-focus**: „Focus is influenced by game world cues (e.g. attractors)“. Objekte und Kreaturen ziehen die Kamera an, und Bosskämpfe nennt er „obvious candidates for this approach“. + + Er beschreibt auch, wie Kameras Gegner verstecken. Bei Rastan Saga bewegt sich die Figur in einem Kamerafenster, bevor die Kamera folgt, und Gegner von oben fallen dadurch weniger auf. Glättung mit lerp kann bei großen, sehr schnellen Figuren dazu führen, dass die Figur den Bildrand erreicht, bevor die Kamera nachzieht, „keeping incoming enemies hidden till the last minute“. Der Artikel behandelt Side-Scroller. Die Übertragung auf die Draufsicht ist deine Arbeit. + + Zoom-to-fit verträgt sich schlecht mit pixelgenauer Darstellung. Die Testszene rendert 320 × 180 Pixel und skaliert ganzzahlig hoch (siehe [Godot-Start](/combat/godot-start)). Pixelart bleibt dabei nur bei ganzzahligen Vergrößerungen gleichmäßig. Ein Boss, der nicht ins Bild passt, braucht also andere Antworten: Kamera verschieben, Teile außerhalb des Bildes ankündigen oder den Gegner so groß machen, wie das Bild es erlaubt. + + Auch die Perspektive ist eine Antwort. Acid Nerve hat für Death's Door früh experimentiert und sich für eine isometrische Ansicht entschieden, anders als die Draufsicht von Titan Souls: „You also get a clearer view of two angles“ ([Press Start](https://press-start.com.au/features/2021/03/27/we-spoke-to-the-team-at-acid-nerve-about-the-stylish-looking-deaths-door/)). Zum Ansehen: Kerens GDC-Vortrag [Boss Up](https://www.youtube.com/watch?v=48Ymh4Ge5j8) und der Clip [How to Make a Good 2D Camera](https://www.youtube.com/watch?v=TdWFzpgnljs) von Game Maker's Toolkit. + + ## Vorhersagbar und auf dem Kern-Kampfsystem + + Ein großer Gegner ist schwer zu überblicken. Umso mehr hängt daran, dass man ihn lesen kann. Die Entwickler von Titan Souls schreiben, die Kämpfe seien „primarily meant to be predictable once the player understands them“. Der Boss Eyecube bewegt sich auf einem Schachbrett ein Feld nach dem anderen und feuert nach jeweils vier Bewegungen einen Laser in Blickrichtung ([Game Developer](https://www.gamedeveloper.com/design/game-design-deep-dive-one-hit-kills-in-i-titan-souls-i-)). Monster Hunter gibt Hinweise über Zustände der Monster, „such as alarmed, enraged, exhausted“ ([Nintendo Life](https://nintendolife.com/news/2015/02/monster_hunter_4_ultimate_director_discusses_monster_design_and_animation)). Bei Silksong hat Team Cherry die Bosse umgebaut: Sie „decide what they want to do and then move into the correct position to do it“ ([Military.com](https://www.military.com/off-duty/games/silksong-devs-explain-how-bosses-were-designed-differently-those-hollow-knight.html)). Erst die Absicht, dann der Weg: Der Spieler sieht die Positionierung und kann daraus lesen, was kommt. + + Insomniac hat für Marvel's Spider-Man 2 die Bosskämpfe auf das Kern-Kampfsystem gestellt. Laut der Beschreibung des GDC-Vortrags von Adam Noonchester entstand das Spektakel „through core combat instead of via one-off, lock-and-key mechanics and disabling player abilities“ ([GDC Vault](https://gdcvault.com/play/1034367/Refining-the-Rogues-Gallery-Building)). Was der Spieler im ganzen Spiel lernt, zählt auch beim Boss. + + Dazu gehört die Frage, ob Treffer einen großen Gegner unterbrechen. Die Testszene hat dafür `interruptible`. Steht es auf `false`, zählt der Treffer, der Gegner blinkt, aber er geht nicht in den Hitstun und wird nicht zurückgestoßen. Er setzt seinen Angriff fort. Den Hitstop, das kurze Einfrieren beim Treffer, bekommt er trotzdem: Im gemessenen Lauf mit dem großen Trainingsgegner rückt sein nächster Telegraph nach einem Treffer um die 4 Frames Hitstop nach hinten. Ein Punish ist dann Schaden in seine Erholung, kein Abbruch seines Angriffs. + + > [!INTERVIEW] Designtest: „Der Boss soll groß wirken, aber Tester werden von Flächen getroffen, die sie nicht gesehen haben“ + > Gute Antwort: erst klären, was „nicht gesehen“ heißt. Lag die Fläche außerhalb des Bildes, war der Telegraph zu kurz für den Weg hinaus, oder hat der Körper des Gegners die Markierung verdeckt? Die zweite Frage rechnest du aus: Weg in Pixeln, Geschwindigkeit, Telegraph-Frames. Für die erste nennst du Kamera-Techniken wie cue-focus oder Ankündigungen am Bildrand, für die dritte prüfst du, ob die Markierung über oder unter dem Körper gezeichnet wird. Dann änderst du eine Größe und testest wieder. Wie groß der Boss am Ende ist, bleibt eine Designentscheidung, die du begründest, nicht eine Zahl, die man auswendig weiß. + + ## Häufige Fehler + + - **Größe zuerst, Lesbarkeit danach.** Ein Gegner, der den halben Bildschirm füllt, kann seine eigenen Markierungen verdecken. Größe und Auflösung gehören an den Anfang, wie bei Shadow of the Colossus. + - **Flächen, aus denen man nicht herauskommt.** Weg in Pixeln durch Geschwindigkeit mal 60, aufgerundet: Ist das mehr als der Telegraph, entkommt niemand zu Fuß. + - **Abrunden statt aufrunden.** 26,7 Frames heißt 27. Mit 26 ist die Figur noch einen Pixel in der Fläche. + - **Zonen ohne Bedeutung.** Kopf und Bein mit gleichem Faktor und gleicher Erreichbarkeit sind zwei Namen für dieselbe Hurtbox. + - **Die Dauer des Phasenwechsels nicht messen.** Unverwundbarkeit beim Wechsel ist Zeit, in der der Spieler nichts tun kann. Supergiant hat sie in Hades nach Playtests verkürzt; ob sie in deinem Kampf eine Pause oder eine Bühne sein soll, entscheidest du mit der Zahl im Log. + - **Übersehen, was ein Boss prüft.** Ein Boss, der gelernte Fähigkeiten abschaltet oder eigene Regeln mitbringt, prüft etwas anderes als der Rest des Spiels. Insomniac baute die Bosse von Spider-Man 2 deshalb auf dem Kern-Kampfsystem auf, Shadow of the Colossus gibt jedem Koloss eine eigene Besiegmethode. Beides geht; wichtig ist, dass du weißt, welches du baust. + - **Zoom für die Übersicht in Pixelart.** Krumme Zoomstufen verzerren Pixel. Plane Kamera und Größe zusammen. + - **Unterbrechbarkeit vergessen.** Ohne bewusste Entscheidung reagiert der Boss wie ein kleiner Gegner auf jeden Treffer, oder auf keinen. + + ## Zusammenfassung + + - Größe und Auflösung bestimmen zusammen, was der Spieler sieht. Shadow of the Colossus setzte beides als erste Vorgabe. + - Ob ein Telegraph reicht, ist eine Rechnung: Pixel / (px/s) × 60, aufgerundet, gegen die Telegraph-Frames. + - Hurtbox-Zonen tragen Namen und Faktoren. Ein Schlag zählt je Gegner einmal, auch über zwei Zonen. + - Design und Besiegmethode hängen zusammen: Hydra mit Nebenköpfen, Kolosse mit eigener Methode und eigener Arena. + - Phasen gliedern den Kampf. Phasenwechsel mit Unverwundbarkeit kosten Zeit. + - Zoom-to-fit und cue-focus helfen bei großen Gegnern, ganzzahlige Pixelart begrenzt den Zoom. + - Vorhersagbarkeit kommt aus Mustern, Zuständen und Bossen, die erst entscheiden und dann Position beziehen. + - `interruptible = false` verhindert Hitstun und Knockback, nicht den Hitstop. +exercises: + - id: besiegmethode-design + type: choice + title: Wie man gewinnt, prägt, wie er aussieht + prompt: | + Welche Aussagen geben die Quellen zu Hades und Shadow of the Colossus richtig wieder? + options: + - text: Ueda sagt, das Design der Kolosse hänge eng damit zusammen, wie man sie besiegt. + correct: true + why: „The design of the Colossi is closely related to how you go about defeating them.“ + - text: Das Team von Shadow of the Colossus prüfte, dass sich die Besiegmethoden der Kolosse nicht überschneiden. + correct: true + why: Ueda beschreibt, dass jede Methode sich von der eines anderen Kolosses unterscheiden sollte. + - text: Bei der Hydra in Hades ist der Hauptkopf von Anfang an das einzige Ziel. + correct: false + why: Gorinstein nennt nachwachsende Nebenköpfe und einen Hauptkopf. Der Mythos, erst die Nebenköpfe, dann der Hauptkopf, prägte die Struktur. + - text: Shadow of the Colossus war von Anfang an mit 16 Kolossen geplant. + correct: false + why: Zuerst waren es 48, dann 24, am Ende 16. + explanation: | + In beiden Spielen folgt die Gestalt des Gegners aus der Frage, wie man ihn besiegt. Bei der Hydra legt die Reihenfolge der Köpfe den Ablauf fest, bei den Kolossen bestimmt die Besiegmethode Körper und Arena. Für Hurtbox-Zonen heißt das: Eine Zone ist erst dann ein Schwachpunkt, wenn der Kampf festlegt, wann und warum man sie trifft. + resources: + - title: DigiPen · Eduardo Gorinstein über Hades + url: https://digipen.edu/showcase/news/eduardo-gorinstein-delivers-challenge-and-fun-hades + - title: GlitterBerri · The Making of the Colossi + url: https://glitterberri.com/extended-developer-interview/making-of-the-colossi/ + + - id: vorhersagbar-quellen + type: choice + title: Lesbare Riesen + prompt: | + Welche Aussagen über Vorhersagbarkeit großer Gegner stimmen laut den Quellen? + options: + - text: Die Kämpfe in Titan Souls sollen vorhersagbar sein, sobald der Spieler sie verstanden hat. + correct: true + why: „fights are primarily meant to be predictable once the player understands them“. + - text: Bosse in Silksong entscheiden zuerst, was sie tun, und gehen dann in Position. + correct: true + why: So beschreibt es William Pellen. In Hollow Knight richteten sie sich stärker nach der Position des Spielers. + - text: Spider-Man 2 baut Bosskämpfe vor allem auf einmalige Sondermechaniken und schaltet Fähigkeiten des Spielers ab. + correct: false + why: Das Gegenteil. Laut Vortragsbeschreibung entsteht das Spektakel über das Kern-Kampfsystem statt über solche Mechaniken. + - text: Monster Hunter verbirgt die Zustände seiner Monster, damit sie unberechenbar bleiben. + correct: false + why: Fujioka beschreibt Hinweise über Zustände wie „alarmed, enraged, exhausted“. + explanation: | + Alle vier Spiele machen große Gegner lesbar, auf verschiedenen Wegen: feste Muster (Titan Souls), sichtbare Absicht durch Positionierung (Silksong), sichtbare Zustände (Monster Hunter) und bekannte Werkzeuge (Spider-Man 2). Ein großer Gegner, dessen Verhalten man nicht lernen kann, wirkt nicht groß, sondern zufällig. + resources: + - title: Military.com · Silksong Devs Explain How Bosses Were Designed Differently + url: https://www.military.com/off-duty/games/silksong-devs-explain-how-bosses-were-designed-differently-those-hollow-knight.html + + - id: rechenweg-flaeche + type: order + title: Kommt die Figur heraus? + prompt: | + Bring die Schritte der Rechnung in eine richtige Reihenfolge: Reicht ein Telegraph, um zu Fuß aus einer Fläche zu kommen? + lang: text + lines: | + Weg bis zum Rand messen (Pixel) + mal 60 nehmen + durch Pixel pro Sekunde teilen + auf ganze Frames aufrunden + mit den Telegraph-Frames vergleichen + alternatives: + - [1, 3, 2, 4, 5] + explanation: | + Ohne Weg gibt es nichts zu rechnen, und ohne ganze Frames nichts zu vergleichen. Ob du zuerst mit 60 malnimmst oder zuerst teilst, ist mathematisch gleich; im Modell rechnet Python zuerst `weg_px * 60`, damit glatte Werte exakt bleiben. Aufgerundet wird erst am Ende, sonst rundest du Zwischenergebnisse und verschiebst das Ergebnis. + resources: + - title: Python · math.ceil + url: https://docs.python.org/3/library/math.html#math.ceil + + - id: radien-pruefen + type: output + title: Welche Flächen gehen zu Fuß? + prompt: | + Telegraph 30 Frames, `move_speed = 90`, alles Platzhalter. Was gibt das Modell aus? + lang: python + code: | + import math + + TELEGRAPH = 30 + + def frames_raus(weg_px, px_pro_s): + return math.ceil(weg_px * 60 / px_pro_s) + + ok = [r for r in (30, 45, 50, 60) + if frames_raus(r, 90) <= TELEGRAPH] + print(frames_raus(50, 90), ok) + expected: | + 34 [30, 45] + hints: + - 50 × 60 / 90 ist 33,3. + - 45 Pixel brauchen genau 30 Frames. + explanation: | + 30 Pixel brauchen 1800 / 90 = 20 Frames, 45 Pixel genau 30. Beide passen in 30 Frames Telegraph, 45 gerade so. 50 Pixel ergeben 33,3, aufgerundet 34; 60 Pixel 40. Der Grenzfall 45 zeigt, warum `<=` richtig ist: Eine Figur, die in genau 30 Frames herauskommt, ist im besten Fall rechtzeitig draußen. + resources: + - title: Python · math.ceil + url: https://docs.python.org/3/library/math.html#math.ceil + + - id: ausweichen-50-pixel + type: output + title: Mit dem Ausweichen hinaus + prompt: | + Wie viele Frames braucht das Ausweichen der Testszene (`dodge_speed = 220`, Platzhalter) für einen Weg von 50 Pixeln? Aufrunden. Antworte mit der Zahl. + lang: text + code: | + Frames = Pixel / (px/s) × 60, aufgerundet + Pixel 50 + px/s 220 + expected: | + 14 + accept: ["14 Frames"] + explanation: | + 50 × 60 / 220 = 13,6, aufgerundet 14. Das Ausweichen dauert in der Testszene 18 Frames und trägt 66 Pixel weit, 50 Pixel schafft es also. Ob es auch schützt, hängt an den i-Frames (Frame 2 bis 12), nicht am Weg. Beides trennst du in [Ausweichen, Risiko und Belohnung](/combat/ausweichen). + + - id: zwei-zonen-ein-treffer + type: output + title: Kopf und Bein zugleich + prompt: | + Ein breiter Schlag der Figur berührt in derselben aktiven Phase die Hurtboxen `kopf` und `bein` des großen Trainingsgegners. Beide sind direkte Kinder des Gegners. Wie viele Treffer zählt der Gegner? Antworte mit der Zahl. + lang: gdscript + code: | + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox or area.get_parent() in already_hit: + return + if (area as Hurtbox).receive(attack, self): + already_hit.append(area.get_parent()) + hitstop_left = attack.hitstop + expected: | + 1 + accept: ["1 Treffer", "einen", "einer"] + explanation: | + Die erste Zone, die gemeldet wird, zählt, und die Figur merkt sich den Gegner über `get_parent()`. Bei der zweiten Zone ist der Gegner schon in `already_hit`, die Funktion kehrt sofort zurück. Geleert wird die Liste erst beim nächsten Eintritt in die aktive Phase. Ob ein Schlag mehrere Zonen treffen soll, ist eine Designentscheidung; diese Regel ist nur die Startregel der Testszene. + resources: + - title: Godot Docs · Area2D.area_entered + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-signal-area-entered + + - id: frames-formel + type: gap + title: Die Formel als Code + prompt: | + Ergänze die Rückgabe: Frames, die die Figur für `weg_px` Pixel mit `px_pro_s` Pixeln pro Sekunde braucht, auf ganze Frames aufgerundet. Ein Frame ist 1/60 Sekunde. + lang: python + code: | + import math + + def frames_raus(weg_px, px_pro_s): + return ⟦math.ceil⟧(⟦weg_px * 60⟧ / px_pro_s) + gaps: + - label: Aufrunden + hint: Die Funktion aus `math`, die auf die nächste ganze Zahl aufrundet. + - label: Zähler + hint: Pixel mal Frames pro Sekunde, dann geteilt durch Pixel pro Sekunde. + accept: ["60 * weg_px", "weg_px * 60.0"] + explanation: | + `weg_px / px_pro_s` sind Sekunden, mal 60 Frames. `math.ceil` rundet auf die nächste ganze Zahl auf. `round` wäre falsch, weil es 26,4 auf 26 abrundet, obwohl die Figur dann noch nicht draußen ist. Zuerst teilen ist mathematisch gleich, kann aber durch Gleitkomma in seltenen Fällen einen Frame mehr ergeben, etwa bei 93 Pixeln und 90 px/s. + resources: + - title: Python · math.ceil + url: https://docs.python.org/3/library/math.html#math.ceil + + - id: kamera-begriffe + type: gap + title: Zwei Begriffe von Itay Keren + prompt: | + Trag die Begriffe aus Kerens Glossar ein. Der erste ändert Zoom oder Abstand, damit relevante Elemente nah im Bild sind. Beim zweiten ziehen Hinweise in der Welt, etwa Attraktoren, den Fokus an. + lang: text + code: | + ⟦zoom-to-fit⟧: Zoom passt sich an + ⟦cue-focus⟧: Attraktoren ziehen den Fokus + gaps: + - label: Zoom oder Abstand anpassen + hint: Drei Wörter mit Bindestrichen, beginnt mit „zoom“. + accept: ["zoom to fit", "Zoom-to-fit", "Zoom to fit"] + - label: Hinweise ziehen den Fokus + hint: Zwei Wörter mit Bindestrich, das zweite ist „focus“. + accept: ["cue focus", "Cue-focus", "Cue focus"] + explanation: | + Keren nennt Bosskämpfe „obvious candidates“ für Attraktoren: Der Boss zieht die Kamera an, damit er im Bild bleibt. Zoom-to-fit holt alles Wichtige ins Bild, verträgt sich aber in Pixelart nur mit ganzzahligen Vergrößerungen. Beide Begriffe stammen aus Side-Scrollern; wie sie in der Draufsicht wirken, prüfst du selbst. + resources: + - title: Itay Keren · Scroll Back + url: https://www.gamedeveloper.com/design/scroll-back-the-theory-and-practice-of-cameras-in-side-scrollers + + - id: bossnotiz-rechnung + type: bug + title: Die Notiz rechnet falsch + prompt: | + Du prüfst eine Fläche des großen Trainingsgegners. Das Ergebnis stimmt, aber eine Zeile darüber nicht. Tippe sie an und korrigiere sie. + lang: text + code: | + Radius: 60 px + move_speed: 90 px/s + Frames raus: 30 + Telegraph: 36 Frames + Ergebnis: reicht nicht + lines: [3] + fix: + - line: 3 + answers: ["Frames raus: 40", "Frames raus: 40 Frames"] + explanation: | + 60 × 60 / 90 = 40 Frames, bei 1,5 Pixeln pro Frame. Mit 30 hätte die Rechnung „reicht“ ergeben, denn 30 ≤ 36. Das Ergebnis „reicht nicht“ ist richtig, weil 40 > 36. Eine falsche Zwischenzeile ist trotzdem ein Fehler: Wer die Notiz später für einen anderen Telegraph benutzt, rechnet mit der 30 weiter. + + - id: abrunden-statt-aufrunden + type: bug + title: Ein Pixel zu wenig + prompt: | + Das Modell sagt für 40 Pixel 26 Frames. Nach 26 Frames ist die Figur mit 1,5 Pixeln pro Frame aber erst 39 Pixel weit und steht noch in der Fläche. Finde die Zeile und korrigiere sie. + lang: python + code: | + import math + + def frames_raus(weg_px, px_pro_s): + return int(weg_px * 60 / px_pro_s) + + print(frames_raus(40, 90)) + lines: [4] + fix: + - line: 4 + answers: ["return math.ceil(weg_px * 60 / px_pro_s)", "return math.ceil(60 * weg_px / px_pro_s)", "return math.ceil(weg_px / px_pro_s * 60)"] + explanation: | + `int()` schneidet die Nachkommastellen ab: 26,67 wird 26. Gebraucht werden aber ganze Frames, in denen die Figur den Rand erreicht, also aufgerundet 27. Nach 27 Frames ist sie 40,5 Pixel weit. `math.ceil` ist hier schon importiert, wird aber nicht benutzt; ein ungenutzter Import ist oft ein Hinweis auf genau diesen Fehler. + resources: + - title: Python · math.ceil + url: https://docs.python.org/3/library/math.html#math.ceil + + - id: nicht-unterbrechbar + type: explain + title: Was interruptible = false bewirkt + prompt: | + Erkläre in wenigen Sätzen, was sich ändert, wenn der große Trainingsgegner `interruptible = false` hat, und was gleich bleibt. Gekürzt aus `enemy.gd`: + lang: gdscript + code: | + func take_hit(attack, source, hurtbox) -> bool: + hits_taken += 1 + # … Log-Zeile und Blinken … + hitstop_left = attack.hitstop + if interruptible: + hitstun_frames = attack.hitstun + # … Knockback … + _enter(State.HITSTUN) + return true + points: + - Der Treffer zählt weiter, mit Log-Zeile und Blinken. + - Der Hitstop wirkt weiter, der Gegner hält für `hitstop` Frames an, und sein Ablauf verschiebt sich um diese Frames. + - Kein Hitstun und kein Knockback, der Gegner bleibt in seinem Zustand und setzt den Angriff fort. + - Treffer unterbrechen ihn nicht mehr; ein Punish heißt Schaden in seine Erholung, nicht Abbruch seines Angriffs. + explanation: | + `interruptible` steuert nur den Teil im `if`: Hitstun und Knockback. Alles davor läuft immer, auch der Hitstop. Im gemessenen Lauf rückt dadurch der nächste Telegraph des großen Trainingsgegners nach einem Treffer um 4 Frames nach hinten. Für den Spieler heißt das: Er kann den Angriff nicht durch Treffer stoppen, sondern muss ausweichen und in die Erholung zurückschlagen. Ob deine großen Gegner unterbrechbar sind, immer, nie oder nur in bestimmten Phasen, entscheidest du. + + - id: clip-cuphead-bosse + type: practice + title: "Clip-Analyse: Phasen und Größe" + prompt: | + Sieh dir den Clip [How Cuphead's Bosses (Try to) Kill You](https://www.youtube.com/watch?v=F8T6Ul4aHTI) von Game Maker's Toolkit an. Wähle zwei Bosse, die du darin siehst, und notiere zu jedem: + + - wie viele Phasen du erkennst und was sich beim Wechsel ändert, + - wie viel vom Bild der Boss einnimmt und wo er steht, + - über welche Kanäle sich ein Angriff ankündigt (Pose, Blinken, Fläche, Ton), + - eine Stelle, an der du als Spieler nicht wüsstest, wohin. + lang: text + solution: | + Beispiel für die Form, am großen Trainingsgegner der + Testszene (Werte sind Platzhalter): + + Gegner: großer Trainingsgegner + Phasen: eine; ein Angriff in der Liste + Größe im Bild: deutlich größer als die Figur + Kanäle: nur gelbe Bodenfläche + Unklar: Steht die Fläche teils außerhalb des + Bildes, sehe ich nicht, wie weit sie reicht. + checklist: + - Zu beiden Bossen stehen Phasen, Größe im Bild und Kanäle. + - Mindestens eine Notiz beschreibt, was sich beim Phasenwechsel ändert. + - Du hast eine Stelle notiert, an der dir als Spieler die Information fehlen würde. + - Was du nur vermutest, ist als Vermutung markiert. + explanation: | + Cuphead ist ein Spiel mit großen Bossen und klaren Phasen. Die Übung schult den Blick, mit dem du später eigene große Gegner prüfst: Was ist im Bild, was kündigt sich wie an, und wo fehlt dem Spieler etwas? Was du siehst, beschreibst du; was es für dein Spiel heißt, entscheidest du später. + resources: + - title: Game Maker's Toolkit · How Cuphead's Bosses (Try to) Kill You + url: https://www.youtube.com/watch?v=F8T6Ul4aHTI + - title: Game Informer · How Studio MDHR Builds A Cuphead Boss + url: https://gameinformer.com/feature/2022/12/27/how-studio-mdhr-builds-a-cuphead-boss + + - id: fragen-eigener-boss + type: practice + title: Die Fragen zu deiner Kreatur + prompt: | + Notiere die Fragen, die du für deinen eigenen großen Gegner beantworten musst, bevor du Werte festlegst. Nur Fragen, noch keine Antworten. Decke dabei Größe und Lesbarkeit, Zonen, Phasen, Kamera, Arena und Unterbrechbarkeit ab. Hinter jede Frage schreibst du, woran du die Antwort später prüfst: Rechnung, Log oder Playtest. + lang: text + solution: | + Beispiel für die Form, am großen Trainingsgegner + der Testszene, nicht für deine Kreatur: + + Stampfer 64 × 40, windup 40: Kommt die Figur + von (100, 120) zu Fuß heraus? + Prüfen: Rechnung, dann Log + Zone kopf: Ist sie aus der Erholung des + Stampfers erreichbar? + Prüfen: Log mit Drehbuch + checklist: + - Größe, Zonen, Phasen, Kamera, Arena und Unterbrechbarkeit kommen je mindestens einmal vor. + - Jede Frage nennt, wie du sie prüfst (Rechnung, Log oder Playtest). + - Die Notiz enthält noch keine Werte für deine Kreatur. + - Mindestens eine Frage betrifft, was der Spieler sieht, nicht nur, was der Gegner tut. + explanation: | + Große Gegner sind teuer zu ändern, weil Zonen, Animation, Kamera und Arena aneinander hängen. Wer die Fragen früh aufschreibt, prüft sie mit grauen Kästen, bevor Pixelart entsteht. Die Liste bleibt deine; die Testszene liefert nur die Werkzeuge, um sie zu beantworten: Rechnung, Log und den großen Trainingsgegner in [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen). + resources: + - title: GlitterBerri · The Graphic Designers Tell It Straight + url: https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/ diff --git a/content/combat/modules/hitboxen.yaml b/content/combat/modules/hitboxen.yaml new file mode 100644 index 0000000..468a495 --- /dev/null +++ b/content/combat/modules/hitboxen.yaml @@ -0,0 +1,501 @@ +id: hitboxen +title: Reichweite, Hitboxen und Hurtboxen in der Draufsicht +level: 2 +minutes: 100 +summary: Was Hitbox und Hurtbox sind, warum Sakurai eine Hitbox größer als die Waffe erlaubt, aber nicht an anderer Stelle, und wie du Reichweite in der Draufsicht nachrechnest, wenn Figuren sich in alle Richtungen drehen und bewegen. +goals: + - Du unterscheidest Hitbox, Hurtbox und Disjointed Hitbox und erklärst, wo sie vom Bild abweichen dürfen und wo nicht. + - Du prüfst, ob sich zwei achsenparallele Rechtecke oder zwei Kreise überlappen, und rechnest daraus die Reichweite eines Angriffs. + - Du erklärst, warum Reichweite in der Draufsicht von der Richtung und von der Form der Flächen abhängt. + - Du rechnest Geschwindigkeiten in Pixel pro Frame um und bestimmst, wie viele Frames eine Figur braucht, um einen Abstand aufzuholen. + - Du beschreibst, wie Kollisionsebenen und die Debug-Anzeige helfen, einen strittigen Treffer zu klären. +resources: + - title: Masahiro Sakurai · Always Keep Attack Collision in Mind + url: https://www.youtube.com/watch?v=rwwF_4blK-o + - title: Infil · The Fighting Game Glossary · Hitbox + url: https://glossary.infil.net/?t=Hitbox + - title: Ian Cofino · Dreamscaper – Killer Combat on an Indie Budget (GDC 2022) + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + - title: Godot Docs · Physics introduction · Collision layers and masks + url: https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-layers-and-masks + - title: Godot Docs · Overview of debugging tools · Visible collision shapes + url: https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + In der Testszene steht der Dummy 20 Pixel rechts von der Spielfigur. Die Hitbox des Platzhalterangriffs ist 14 × 10 Pixel groß, ihre Mitte sitzt 12 Pixel vor der Figur, ihr vorderer Rand also 19 Pixel vor der Figurenmitte. Die Hurtbox des Dummys ist 12 Pixel breit, ihr linker Rand liegt 14 Pixel vor der Figurenmitte. Die Flächen überlappen 5 Pixel, und das Log meldet `Dummy getroffen`. Mit Godot 4.7.2 nachgemessen: Schiebst du den Dummy auf 25 Pixel, berühren sich die Ränder nur noch, und es trifft trotzdem. Bei 26 Pixeln trifft der Angriff nicht mehr. + + Ob ein Angriff trifft, entscheidet Geometrie in den Frames, in denen die Hitbox an ist. Diese Lektion klärt, was Hitbox und Hurtbox sind, warum sie vom Bild abweichen dürfen und wie du Reichweite in der Draufsicht nachrechnest. Für Reichweite und Drehung in der Draufsicht nennt keine der Quellen dieses Kurses Regeln; Ian Cofino beschreibt nur, wie großzügig Dreamscaper die Flächen setzt. Was dazu folgt, ist Rechnung und Messung an der Testszene mit Platzhalterwerten, keine Regel aus einem Studio. + + ## Hitbox und Hurtbox: zwei Flächen, zwei Fragen + + Die **Hitbox** ist die Fläche, mit der ein Angriff trifft. Infil beschreibt sie als vorgegebene Fläche, „usually a group of rectangles or circles“, die es nur in der aktiven Phase gibt; ihre Größe bestimmt die Reichweite ([Hitbox](https://glossary.infil.net/?t=Hitbox)). Die **Hurtbox** ist die Fläche, an der eine Figur getroffen werden kann. Meist versucht sie, dem Körper der Figur recht genau zu folgen ([Hurtbox](https://glossary.infil.net/?t=Hurtbox)). Ein Treffer ist die Überlappung einer Hitbox mit einer Hurtbox der Gegenseite. Im Alltag heißt beides oft „Hitbox“. Wenn jemand sagt „die Hitbox ist kaputt“, frag nach, welche Fläche gemeint ist. + + In der Testszene sind alle Flächen Rechtecke. Die Werte sind Platzhalter: + + | Fläche | Größe | Lage | + | --- | --- | --- | + | Bild Figur | 12 × 12 | Mitte | + | Hurtbox Figur | 10 × 10 | Mitte | + | Hitbox | 14 × 10 | 12 px voraus | + | Hurtbox Dummy | 12 × 16 | Mitte | + + Die Hurtbox der Figur ist an jeder Seite einen Pixel kleiner als ihr Bild. Die Hitbox reicht von 5 bis 19 Pixel vor der Figurenmitte. Sie beginnt also genau dort, wo die Hurtbox der Figur endet. Der Arm, der zuschlägt, ist nicht verwundbar. + + Im Bereich der Hitbox liegt keine Hurtbox des Angreifers. Der Gegner kann den ausgestreckten Arm also nicht treffen, wie bei einer **Disjointed Hitbox**, einer Hitbox, die nicht in der Nähe einer Hurtbox des Angreifers liegt ([Disjointed Hitbox](https://glossary.infil.net/?t=Disjointed+Hitbox)). Ganz so weit weg ist sie hier nicht, sie beginnt direkt am Rand der Figur. Typisch für Disjointed Hitboxes sind Schwertangriffe und Projektile. Sonst legen Designer an die Stelle eines Schlags auch Hurtboxen, damit der Gegner etwas zum Treffen hat, wenn der Angreifer verfehlt. Eine Disjointed Hitbox nimmt dem Gegner diese Antwort. Ob deine Angriffe so sind, entscheidest du später selbst. + + ## Größer als die Waffe, aber an der richtigen Stelle + + Sakurai erklärt in [Always Keep Attack Collision in Mind](https://www.youtube.com/watch?v=rwwF_4blK-o), dass die Hitboxen in Smash nicht der Form von Schwert oder Faust folgen, sondern eine größere Fläche abdecken. Weil sich die Figuren schnell bewegen, wären Treffer sonst selten und das Spiel frustrierend (ab 0:16). Dann zieht er eine Grenze (ab 1:02): Ein Größenunterschied zwischen Bild und Hitbox ist für den Spielspaß in Ordnung. Sitzt die Hitbox aber an einer anderen Stelle als die Bewegung im Bild, wird der Widerspruch viel größer. Ein Schlag, der im Bild nicht durch die Mittellinie geht, trifft schlecht, und Sakurai lässt dann die Animation korrigieren. Die Hitbox an eine andere Stelle zu setzen, ginge auch, aber dann passen Bild und Treffer noch weniger zusammen. Und weil der Hitstop die Pose beim Treffer einfriert, darf die Hitbox bei einem Schwertschlag schon kurz vor dem vollen Schwung beginnen (ab 1:55). + + > [!MERKE] + > Nach Sakurai darf die Größe einer Trefferfläche vom Bild abweichen, ihre Lage nicht. + + Ian Cofino beschreibt für das Top-down-Spiel Dreamscaper, wie weit das gehen kann ([GDC 2022, ab 34:52](https://www.youtube.com/watch?v=3Omb5exWpd4&t=2092s)). Das Team verändert Hitboxen und Hurtboxen zugunsten des Spielers. Ein Treffer darf zählen, obwohl im Bild kein Kontakt zu sehen ist. Spielerangriffe bekommen großzügige Hitboxen, Gegnerangriffe präzise. Ein Angriff kann mehrere Hitboxen haben: Projektile prüfen Gegner, Umgebung und Zerstörbares getrennt. Gegen Gegner ist die Fläche großzügig, an Wänden bleibt das Projektil trotzdem nicht hängen. + + God of War (2018) geht noch weiter: Schon Trefferreaktionen am Boden lösten unsichtbare Kollisionen aus, die Gegner daneben treffen. Für Gegner in der Luft machte das Team diese Flächen viel größer ([Evolving Combat in God of War, ab 41:21](https://www.youtube.com/watch?v=hE5tWF-Ou2k&t=2481s)). Das sind Flächen ganz ohne Bild, die es nur für den Spielablauf gibt. + + Im Hitstop lässt Sakurai die Figuren nur im Bild zittern, die Hurtboxen bleiben stehen ([Thinking About Hitstop](https://sourcegaming.info/2015/11/11/thoughts-on-hitstop-sakurais-famitsu-column-vol-490-1/), Fan-Übersetzung); mehr dazu in [Trefferfeedback](/combat/trefferfeedback). + + ## Reichweite in der Draufsicht: Pivot, Richtung, Form + + In einer Seitenansicht schlägt ein Angriff nach links oder rechts. In der Draufsicht mit analogem Stick zeigt er in jede Richtung. Die Testszene löst das mit einem Pivot: `HitboxPivot` ist ein leerer Node in der Mitte der Figur, die Hitbox hängt 12 Pixel rechts davon. Beim Laufen setzt die Figur `hitbox_pivot.rotation = facing.angle()`, und die Hitbox wandert um die Figur herum ([Godot-Start](/combat/godot-start)). + + Für Rechtecke, deren Kanten an den Achsen ausgerichtet sind, ist die Prüfung kurz: Zwei Rechtecke überlappen, wenn sie sich auf der x-Achse und auf der y-Achse überlappen. Das Modell rechnet die drei Abstände vom Anfang nach: + + ```python run + # Rechteck = (links, oben, rechts, unten) + def rechteck(mx, my, breite, hoehe): + return (mx - breite / 2, my - hoehe / 2, + mx + breite / 2, my + hoehe / 2) + + def ueberlappt(a, b): + x = a[0] <= b[2] and b[0] <= a[2] + y = a[1] <= b[3] and b[1] <= a[3] + return x and y + + hitbox = rechteck(12, 0, 14, 10) + for d in (24, 25, 26): + dummy = rechteck(d, 0, 12, 16) + print(d, ueberlappt(hitbox, dummy)) + ``` + + Das `<=` lässt berührende Ränder als Treffer gelten, wie es Godot in der Testszene bei 25 Pixeln getan hat. Daraus folgt eine Faustformel für die Reichweite nach rechts: vorderer Rand der Hitbox plus halbe Breite der Ziel-Hurtbox, 19 + 6 = 25 Pixel Mittenabstand. + + ### Die Richtung ändert die Reichweite + + Dreht sich die Figur, dreht sich die Hitbox mit, die Hurtbox des Dummys aber nicht. Der Dummy ist 12 breit und 16 hoch. Derselbe Angriff trifft deshalb in jeder Richtung bis zu einem anderen Abstand, gemessen mit Godot 4.7.2: + + | Richtung | trifft bis Mittenabstand | + | --- | --- | + | rechts | 25 px | + | unten | 27 px | + | schräg 45° | knapp 29 px | + + Nach unten zählt die halbe Höhe des Dummys statt der halben Breite: 19 + 8 = 27. Schräg prüft Godot die gedrehte Hitbox gegen ein nicht gedrehtes Rechteck. Das rechnest du nicht von Hand, dafür gibt es die Debug-Anzeige. Ob dich diese Unterschiede von 4 Pixeln stören, merkst du erst beim Spielen. Wichtig ist, dass du weißt, woher sie kommen. + + ### Formen: Rechteck, Kreis, Kapsel + + Ein Kreis sieht aus jeder Richtung gleich aus. Bei einer kreisförmigen Hitbox gegen eine kreisförmige Hurtbox hängt die Reichweite deshalb nicht von der Richtung ab. Zwei Kreise überlappen, wenn der Abstand ihrer Mitten höchstens die Summe der Radien ist: + + ```python run + import math + + def treffen(a, b): + (ax, ay, ar), (bx, by, br) = a, b + abstand = math.hypot(bx - ax, by - ay) + return abstand <= ar + br + + def kreis(abstand, winkel, r): + w = math.radians(winkel) + return (abstand * math.cos(w), + abstand * math.sin(w), r) + + # Hitbox r 7, 12 px voraus; Ziel r 6 + for winkel in (0, 45, 90): + h = kreis(12, winkel, 7) + nah = treffen(h, kreis(24, winkel, 6)) + fern = treffen(h, kreis(26, winkel, 6)) + print(winkel, nah, fern) + ``` + + In allen drei Richtungen trifft der Angriff bei 24 Pixeln und verfehlt bei 26. Godot bietet für 2D unter anderem `RectangleShape2D`, `CircleShape2D` und `CapsuleShape2D`, ein Rechteck mit runden Enden. Laut Doku ist der Kreis am schnellsten zu prüfen, das Rechteck schneller als die Kapsel ([CircleShape2D](https://docs.godotengine.org/en/stable/classes/class_circleshape2d.html), [CapsuleShape2D](https://docs.godotengine.org/en/stable/classes/class_capsuleshape2d.html)). Bei einer Handvoll Figuren spielt das keine Rolle. Wichtiger ist, welche Form zu Bild und Bewegung passt. + + ### Pixelart-Silhouette und Hurtbox + + Eine Pixelart-Figur ändert ihre Silhouette mit jeder Zeichnung: Arme, Haare, Waffe, Schatten. Die Hurtbox ist dagegen meist ein einzelnes Rechteck oder ein Kreis und hält still. Beide stimmen deshalb nie ganz überein. Nach Sakurais Grenze ist das in Ordnung, solange die Lage stimmt. Wenn du Figuren schräg von vorn zeichnest, steht der Kopf im Bild über den Füßen. Eine Hurtbox, die das ganze Bild abdeckt, wäre nach oben länger als die Figur am Boden, und ein Schlag knapp über dem Kopf träfe. Ob die Hurtbox den Körper, die Füße oder einen Teil dazwischen abdeckt, ist eine Entscheidung, die du für dein Spiel triffst. Wie man klärt, ob ein Treffer „unfair“ war, behandelt [Schwer, unfair oder kaputt?](/combat/frust-diagnose). + + ## Bewegung: Pixel pro Frame und Reichweite, wenn beide laufen + + Geschwindigkeiten stehen in Godot in Pixeln pro Sekunde; `move_and_slide()` rechnet den Anteil eines Physik-Frames selbst aus ([CharacterBody2D](https://docs.godotengine.org/en/stable/classes/class_characterbody2d.html#class-characterbody2d-property-velocity)). Für Reichweiten brauchst du Pixel pro Frame: Tempo geteilt durch 60. Die Platzhalterfigur läuft mit 90 px/s, also 1,5 Pixel pro Frame. Godot zeichnet sie trotzdem nur in ganzen Pixeln. + + In der Testszene steht die Figur während des Wind-ups still. Der Gegner nicht. Weicht ein Gegner mit 60 px/s zurück (Platzhalter), also mit einem Pixel pro Frame, ist er nach den 8 Frames Wind-up rund 8 Pixel weiter weg. In diesem Modell muss er beim Druck höchstens etwa 25 − 8 = 17 Pixel entfernt sein, damit der Angriff ihn nach rechts noch trifft. Kommt er mit demselben Tempo auf die Figur zu, darf er beim Druck bis etwa 33 Pixel entfernt sein. „In Reichweite“ heißt also: In einem aktiven Frame überlappen sich die Flächen. Wo der Gegner beim Druck stand, ist nur der Ausgangspunkt. Auf den Frame genau zeigt es dir nur das Log. + + Wie lange dauert es, eine Lücke zu schließen? Holt eine Figur mit 90 px/s einen Gegner mit 60 px/s ein, schrumpft der Abstand um 0,5 Pixel pro Frame: + + ```python run + import math + + def frames_zum_aufholen(luecke, ich, ziel): + # Tempo in px/s, Lücke in px + pro_frame = (ich - ziel) / 60 + return math.ceil(luecke / pro_frame) + + print(frames_zum_aufholen(10, 90, 60)) + print(frames_zum_aufholen(10, 90, 0)) + ``` + + 10 Pixel gegen einen fliehenden Gegner dauern 20 Frames, gegen einen stehenden 7. Zusammen mit dem Wind-up ergibt das die Zeit, die ein Spieler vom Losrennen bis zum Treffer braucht. Ob dein Angriff einen Schritt nach vorn macht, ob die Figur im Wind-up laufen darf und wie schnell Gegner zurückweichen, sind Designfragen. Die Rechnung zeigt nur, welche Größen zusammenhängen. + + ## Kollisionsebenen und Debug-Anzeige + + Damit eine Hitbox nur die richtigen Flächen findet, liegt jede Fläche auf einer Ebene und scannt bestimmte andere. Die Godot-Doku fasst die Regel so: „Object A can detect a contact with object B only if object B is in any of the layers that object A scans.“ ([Collision layers and masks](https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-layers-and-masks)). In der Testszene liegt die Hurtbox der Figur auf Ebene 2, die des Gegners auf Ebene 3. Die Hitbox der Figur scannt Ebene 3, die des Gegners Ebene 2. So trifft kein Angriff die eigene Figur. Die Einrichtung Schritt für Schritt steht in [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff). Dreamscapers getrennte Prüfung für Gegner, Umgebung und Zerstörbares ließe sich in Godot mit einer Fläche je Ebene nachbauen. + + Sehen kannst du die Flächen im fertigen Spiel nicht. Manche Trainingsmodi von Kampfspielen zeigen sie, Hitboxen fast immer rot und Hurtboxen grün (Infil, [Hitbox](https://glossary.infil.net/?t=Hitbox)). In Godot schaltest du im Menü *Debug* **Kollisionsformen sichtbar** an ([Visible collision shapes](https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes)). Mit Pause (F9) und Nächster Frame (F10) gehst du dann Bild für Bild durch einen strittigen Treffer und siehst gedrehte Hitboxen im aktiven Frame. Prüf am Frame-Zähler der Debug-Anzeige, wie weit ein Schritt geht. + + > [!PRAXIS] + > Ist ein Treffer strittig, schau dir zuerst den aktiven Frame mit sichtbaren Formen an und ändere erst danach etwas. Ein Treffer ohne Kontakt im Bild kann gewollt sein: Dreamscaper nimmt ihn bei Spielerangriffen bewusst in Kauf und hält die Gegner-Hitboxen präzise. Schreib in jede Vorgabe für einen Coding-Agenten Form, Größe und Lage jeder Fläche relativ zur Figurenmitte und die Ebenen, die sie scannt. + + > [!INTERVIEW] + > „Spieler beschweren sich, dass ein Gegner sie trifft, obwohl seine Klaue sie nicht berührt. Wie gehst du vor?“ Gute Antwort: Stell die Szene nach, schalte die Kollisionsformen sichtbar und geh Frame für Frame durch die aktive Phase. Dann prüfst du drei Dinge: Liegt die Hitbox dort, wo die Klaue im Bild ist, oder nur ungefähr? Wo sitzt die Hurtbox des Spielers im Verhältnis zu seinem Bild? Und trifft eine gedrehte Hitbox schräg weiter als gerade? Danach entscheidest du, ob du die Hitbox anpasst oder die Animation, und testest den Fall erneut. + + ## Häufige Fehler + + - **Größe der Hitbox nicht im aktiven Frame geprüft.** Ob eine Fläche, die genau der Waffe folgt, bei schneller Bewegung noch trifft, siehst du nur mit sichtbaren Formen im Einzelbild. Sakurai macht sie bewusst größer; wie groß deine sind, entscheidest du nach der Messung. + - **Lage der Hitbox nicht mit dem Bild verglichen.** Sitzt die Fläche versehentlich woanders als der Schlag, wirken Treffer unerklärlich, auch wenn die Größe passt. Prüf im aktiven Frame mit sichtbaren Formen erst die Animation, dann die Fläche. + - **Reichweite bis zur Mitte des Gegners gerechnet.** Die Hurtbox hat eine Größe. Zur Reichweite gehört ihre halbe Breite oder Höhe dazu. + - **Nur in eine Richtung geprüft.** Mit Rechtecken trifft derselbe Angriff je nach Richtung verschieden weit, in der Testszene nach unten und schräg weiter als nach rechts. + - **px/s und Pixel pro Frame verwechselt.** 90 px/s sind 1,5 Pixel pro Frame, nicht 90. + - **Bewegung im Wind-up vergessen.** Ein Gegner, der beim Druck in Reichweite war, kann im aktiven Frame schon draußen sein. + - **Maske auf die eigene Hurtbox-Ebene gesetzt.** Dann trifft die Figur sich selbst oder ihre Verbündeten. + + ## Zusammenfassung + + - Die Hitbox trifft, die Hurtbox wird getroffen. Ein Treffer ist ihre Überlappung in einem aktiven Frame. + - Sakurai macht Hitboxen größer als die Waffe, aber nicht an einer anderen Stelle. Hurtboxen bleiben bei ihm im Hitstop stehen. + - Dreamscaper verschiebt Flächen gezielt zugunsten des Spielers: großzügige Hitboxen für Spieler-, präzise für Gegnerangriffe. + - In der Draufsicht dreht ein Pivot die Hitbox um die Figur. Mit Rechtecken hängt die Reichweite von der Richtung ab, mit Kreis gegen Kreis nicht. + - Zwei Rechtecke überlappen, wenn sie sich auf beiden Achsen überlappen; zwei Kreise, wenn der Mittenabstand höchstens die Summe der Radien ist. + - Pixel pro Frame = px/s ÷ 60. Bewegt sich ein Gegner im Wind-up, zählt seine Lage im aktiven Frame, nicht beim Druck. + - Kollisionsebenen legen fest, wer wen finden kann. Die Debug-Anzeige mit Einzelbild-Schritt klärt strittige Treffer. +exercises: + - id: flaechen-unterscheiden + type: choice + title: Welche Fläche ist gemeint? + prompt: | + Welche Aussagen über Hitbox und Hurtbox stimmen? + options: + - text: Die Hitbox eines Angriffs gibt es nur in seiner aktiven Phase. + correct: true + why: Infil definiert die aktive Phase genau als die Zeit, in der eine Hitbox da ist. Im Wind-up und in der Erholung hat der Angriff keine. + - text: Eine Disjointed Hitbox liegt nicht in der Nähe einer Hurtbox des Angreifers, deshalb kann der Gegner auf sie nicht zurückschlagen. + correct: true + why: Typisch für Schwerter und Projektile. Wer verfehlt, bietet an dieser Stelle trotzdem nichts zum Treffen. + - text: Eine Hurtbox muss jedem Pixel der Silhouette folgen, sonst ist ein Treffer unfair. + correct: false + why: Die Silhouette ändert sich mit jeder Zeichnung. Sakurai lässt Größenunterschiede bewusst zu; nur die Lage muss stimmen. + - text: Eine Hitbox, die größer ist als die Waffe im Bild, ist ein Fehler. + correct: false + why: In Smash sind Hitboxen absichtlich größer als Schwert oder Faust, weil Treffer bei schneller Bewegung sonst selten wären. + explanation: | + Hitbox und Hurtbox beantworten verschiedene Fragen: womit ein Angriff trifft und wo eine Figur verwundbar ist. Beide dürfen vom Bild abweichen, weil das Bild sich ständig ändert und schnelle Bewegung genaue Formen bestraft. Die Grenze zieht die Lage, nicht die Größe. Eine Disjointed Hitbox ist eine bewusste Entscheidung: Sie nimmt dem Gegner die Möglichkeit, auf den angreifenden Arm zu antworten. + resources: + - title: Infil · Disjointed Hitbox + url: https://glossary.infil.net/?t=Disjointed+Hitbox + + - id: groesse-oder-lage + type: choice + title: Was Sakurai in Kauf nimmt + prompt: | + Welche Abweichungen zwischen Bild und Trefferfläche nimmt Sakurai bewusst in Kauf? + options: + - text: Die Hitbox ist größer als das Schwert im Bild. + correct: true + why: Ein Größenunterschied ist für ihn zugunsten des Spielspaßes in Ordnung. + - text: Die Hitbox beginnt kurz bevor das Schwert ganz durchgeschwungen ist. + correct: true + why: Der Hitstop friert die Pose beim Treffer ein, und die soll nach Angriff aussehen. Deshalb wählt er den Zeitpunkt im Schwung bewusst. + - text: Die Hitbox sitzt neben der Bahn des Schwungs, damit der Angriff öfter trifft. + correct: false + why: Genau das lehnt er ab. Weicht die Lage ab, wird der Widerspruch zwischen Bild und Treffer viel größer. Er lässt dann die Animation ändern. + - text: Die Hurtbox zittert im Hitstop zusammen mit der Figur. + correct: false + why: Die Figuren zittern nur im Bild, die Hurtboxen bleiben stehen. Sonst könnten Angriffe, die treffen sollten, vorbeigehen. + explanation: | + Sakurais Regel lässt sich in einem Satz sagen: Die Größe darf abweichen, die Lage nicht. Größere Flächen machen schnelle Kämpfe spielbar, ohne dass ein Treffer unerklärlich wirkt, solange er dort passiert, wo der Schlag im Bild ist. Ein früherer Beginn im Schwung ist eine Frage des Timings, nicht der Lage. Und alles, was nur der Darstellung dient, wie das Zittern im Hitstop, bewegt die Trefferflächen nicht. + resources: + - title: Masahiro Sakurai · Always Keep Attack Collision in Mind + url: https://www.youtube.com/watch?v=rwwF_4blK-o + + - id: treffer-ablauf + type: order + title: Vom Wind-up zum Hitstop + prompt: | + Bring die Schritte eines Treffers in der Testszene in die Reihenfolge, in der sie passieren. + lang: text + lines: | + Der Wind-up endet, die Figur wechselt nach ACTIVE + Die Form der Hitbox wird mit set_deferred eingeschaltet + Der Physik-Schritt findet die Überlappung + Im nächsten Frame kommt area_entered + Die Hurtbox gibt den Treffer an ihre Figur weiter + Die Figur nimmt ihn an, der Hitstop beginnt + explanation: | + Die Hitbox geht mit dem Zustandswechsel an, aber erst der Physik-Schritt am Ende des Frames prüft, ob sie eine Hurtbox auf einer gescannten Ebene überlappt. Das Signal kommt deshalb einen Frame nach dem Einschalten. Danach fragt die Hitbox über die Hurtbox bei der getroffenen Figur nach, und nur wenn diese annimmt, zählt der Treffer und beginnt der Hitstop. So kann eine Figur in ihren i-Frames einen Treffer ablehnen. + resources: + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html + + - id: ueberlappung-vorhersagen + type: output + title: Trifft es oder nicht? + prompt: | + Was gibt das Programm aus? Die Antwort ist eine Zeile. + lang: python + code: | + # Rechteck = (links, oben, rechts, unten) + def ueberlappt(a, b): + x = a[0] <= b[2] and b[0] <= a[2] + y = a[1] <= b[3] and b[1] <= a[3] + return x and y + + hitbox = (5, -5, 19, 5) + nah = (18, -8, 30, 8) + oben = (10, -30, 20, -6) + a = ueberlappt(hitbox, nah) + b = ueberlappt(hitbox, oben) + print(a, b) + expected: | + True False + explanation: | + `nah` überlappt auf der x-Achse (18 bis 19) und auf der y-Achse (−5 bis 5), also `True`. `oben` überlappt auf der x-Achse, endet aber bei y = −6 und damit einen Pixel über der Oberkante der Hitbox bei −5. Eine Achse ohne Überlappung reicht für `False`. Genau so prüft man zwei achsenparallele Rechtecke: beide Achsen müssen sich überlappen. + + - id: reichweite-nach-unten + type: output + title: Reichweite nach unten + prompt: | + Die Figur der Testszene schaut nach unten, die Hitbox (14 × 10) hängt am Pivot und zeigt mit ihrer Mitte 12 Pixel nach unten. Der Dummy (Hurtbox 12 × 16, nicht gedreht) steht genau unter der Figur. Bis zu welchem Mittenabstand in Pixeln trifft der Angriff? Berührende Ränder zählen. + lang: text + code: | + Hitbox: 14 × 10, Mitte 12 px voraus, + lange Seite in Blickrichtung + Dummy-Hurtbox: 12 breit, 16 hoch + expected: | + 27 + accept: + - 27 px + - 27 Pixel + hints: + - Der vordere Rand der Hitbox liegt 12 + 14 / 2 Pixel vor der Figurenmitte. + - Nach unten zählt die halbe Höhe des Dummys, nicht die halbe Breite. + explanation: | + Gedreht zeigt die Hitbox mit ihrer langen Seite nach unten, ihr vorderer Rand liegt 12 + 7 = 19 Pixel unter der Figurenmitte. Die Hurtbox des Dummys reicht 8 Pixel über seine Mitte hinaus. Also trifft es bis 19 + 8 = 27 Pixel Mittenabstand. Nach rechts sind es 19 + 6 = 25. In Godot 4.7.2 nachgemessen: 27 trifft, 27,5 nicht. + resources: + - title: Godot Docs · RectangleShape2D + url: https://docs.godotengine.org/en/stable/classes/class_rectangleshape2d.html + + - id: wind-up-flucht + type: output + title: Der Gegner weicht zurück + prompt: | + Ein Gegner weicht mit 60 px/s zurück (Platzhalter). Die Figur steht während ihres Wind-ups von 8 Frames still. Um wie viele Pixel wächst der Abstand in dieser Zeit? Antworte mit der Zahl. + lang: text + code: | + Gegner: 60 px/s, Physik: 60 Frames/s + Wind-up der Figur: 8 Frames + expected: | + 8 + accept: + - 8 px + - 8 Pixel + explanation: | + 60 px/s geteilt durch 60 Frames pro Sekunde ergibt 1 Pixel pro Frame, in 8 Frames also 8 Pixel. Steht der Gegner beim Druck 20 Pixel entfernt und reicht der Angriff 25, ist er im ersten aktiven Frame bei etwa 28 und damit draußen. Wo der Gegner beim Druck stand, sagt deshalb wenig; es zählt, wo er im aktiven Frame steht. + resources: + - title: Godot Docs · CharacterBody2D.velocity + url: https://docs.godotengine.org/en/stable/classes/class_characterbody2d.html#class-characterbody2d-property-velocity + + - id: aufholen-frames + type: output + title: Wie lange bis zur Reichweite? + prompt: | + Die Figur läuft mit 90 px/s auf einen Gegner zu, der mit 45 px/s flieht (beides Platzhalter). Bis zur Reichweite fehlen 6 Pixel. Wie viele Frames braucht die Figur mindestens? Antworte mit der Zahl. + lang: text + code: | + Figur: 90 px/s + Gegner: 45 px/s, gleiche Richtung + Lücke: 6 px + expected: | + 8 + accept: + - 8 Frames + hints: + - Rechne beide Tempi in Pixel pro Frame um. + - Pro Frame schrumpft die Lücke um die Differenz. + explanation: | + 90 px/s sind 1,5 Pixel pro Frame, 45 px/s sind 0,75. Die Lücke schrumpft um 0,75 Pixel pro Frame. 6 ÷ 0,75 = 8 Frames, rund 133 ms. Stünde der Gegner still, wären es 6 ÷ 1,5 = 4 Frames. Flieht er mit halbem Tempo der Figur, dauert das Aufholen doppelt so lange. + resources: + - title: Godot Docs · 2D movement overview + url: https://docs.godotengine.org/en/stable/tutorials/2d/2d_movement.html + + - id: rechtecke-ergaenzen + type: gap + title: Zwei Achsen, zwei Bedingungen + prompt: | + Ergänze die Prüfung, ob sich zwei achsenparallele Rechtecke überlappen. Ein Rechteck ist `(links, oben, rechts, unten)`, berührende Ränder zählen als Überlappung. + lang: python + code: | + def ueberlappt(a, b): + x_ok = a[0] <= b[2] and ⟦b[0] <= a[2]⟧ + y_ok = a[1] <= b[3] and ⟦b[1] <= a[3]⟧ + return ⟦x_ok and y_ok⟧ + gaps: + - label: x-Achse, zweite Hälfte + hint: Der linke Rand von b darf nicht rechts vom rechten Rand von a liegen. + accept: ["a[2] >= b[0]"] + - label: y-Achse, zweite Hälfte + hint: Dieselbe Bedingung für oben und unten. + accept: ["a[3] >= b[1]"] + - label: Ergebnis + hint: Beide Achsen müssen sich überlappen. + accept: ["y_ok and x_ok"] + explanation: | + Auf einer Achse überlappen zwei Strecken, wenn jede beginnt, bevor die andere endet. Das sind zwei Vergleiche je Achse. Erst wenn beide Achsen überlappen, überlappen die Rechtecke. Mit `<` statt `<=` würden berührende Ränder nicht zählen; in der Testszene hat Godot sie als Treffer gemeldet, deshalb nutzt das Modell `<=`. + resources: + - title: Godot Docs · RectangleShape2D + url: https://docs.godotengine.org/en/stable/classes/class_rectangleshape2d.html + + - id: ziel-als-punkt + type: bug + title: Der Gegner ist kein Punkt + prompt: | + Das Modell soll prüfen, ob eine kreisförmige Hitbox eine kreisförmige Hurtbox trifft. Eine Hitbox mit Radius 7 und eine Hurtbox mit Radius 6, deren Mitten 10 Pixel auseinander liegen, überlappen sich deutlich. Das Programm gibt aber `False` aus. Finde die Zeile und korrigiere sie. + lang: python + code: | + import math + + def trifft(hx, hy, hr, zx, zy, zr): + abstand = math.hypot(zx - hx, zy - hy) + return abstand <= hr + + print(trifft(0, 0, 7, 10, 0, 6)) + lines: [5] + fix: + - line: 5 + answers: + - return abstand <= hr + zr + - return abstand <= zr + hr + - return hr + zr >= abstand + explanation: | + Die Zeile prüft nur, ob die **Mitte** des Ziels in der Hitbox liegt. Die Hurtbox hat aber selbst einen Radius. Zwei Kreise überlappen, wenn der Mittenabstand höchstens die Summe beider Radien ist: 10 ≤ 7 + 6. Derselbe Fehler steckt in jeder Reichweitenrechnung, die bis zur Gegnermitte misst. Er lässt Angriffe im Modell zu kurz erscheinen, und wer danach abstimmt, macht die Hitbox größer als nötig. + resources: + - title: Godot Docs · CircleShape2D + url: https://docs.godotengine.org/en/stable/classes/class_circleshape2d.html + + - id: vorderkante-falsch + type: bug + title: Eine Notiz mit zu viel Reichweite + prompt: | + Du notierst die Hitbox des Platzhalterangriffs für eine Vorgabe. Mit dieser Notiz rechnest du eine Reichweite nach rechts von 32 Pixeln aus, im Test trifft der Angriff ab 26 Pixeln nicht mehr. Finde die Zeile und korrigiere sie. + lang: text + code: | + Hitbox Platzhalterangriff (Testszene) + Form Rechteck 14 × 10 px + Lage Mitte 12 px vor der Figurenmitte + Vorderkante 26 px vor der Figurenmitte + Hinterkante 5 px vor der Figurenmitte + lines: [4] + fix: + - line: 4 + answers: + - Vorderkante 19 px vor der Figurenmitte + explanation: | + Die Vorderkante liegt eine halbe Länge vor der Mitte der Hitbox: 12 + 14 / 2 = 19. Die Notiz hat die ganze Länge addiert. Die Hinterkante (12 − 7 = 5) war richtig, das hätte auffallen können: Vorder- und Hinterkante müssen 14 Pixel auseinanderliegen. Mit 19 Pixeln Vorderkante und 6 Pixeln halber Dummy-Breite ergibt sich die gemessene Reichweite von 25 Pixeln. + resources: + - title: Godot Docs · RectangleShape2D + url: https://docs.godotengine.org/en/stable/classes/class_rectangleshape2d.html + + - id: schraeg-weiter + type: explain + title: Schräg trifft es weiter + prompt: | + Gemessen mit Godot 4.7.2: Derselbe Platzhalterangriff trifft den Dummy nach rechts bis 25 Pixel Abstand, nach unten bis 27 und schräg im Winkel von 45° bis knapp 29. Erkläre in wenigen Sätzen, woher die Unterschiede kommen. + lang: text + code: | + Hitbox 14 × 10, Mitte 12 px vor der Figur, + dreht sich mit dem Pivot + Hurtbox 12 × 16, dreht sich nicht + rechts trifft bis 25 px + unten trifft bis 27 px + 45° trifft bis knapp 29 px + points: + - Die Hitbox dreht sich mit, die Hurtbox des Dummys nicht; je nach Richtung treffen andere Seiten aufeinander. + - Nach unten ragt die Hurtbox mit ihrer langen Seite (16 px) der Hitbox entgegen, deshalb reicht der Angriff weiter als nach rechts. + - Schräg zeigt eine Ecke des gedrehten Rechtecks nach vorn; die Ecke liegt weiter von der Mitte entfernt als die Seitenmitte. + - Mit 360°-Bewegung hat jede Richtung ihre eigene Reichweite; prüfen lässt sie sich nur mit Rechnung oder Messung. + explanation: | + Ein Rechteck ist nicht in alle Richtungen gleich groß. Nach rechts trifft die Vorderkante der Hitbox auf die schmale Seite der Hurtbox, nach unten auf ihre längere Seite, und schräg zeigt die Ecke der gedrehten Hitbox nach vorn. Daraus folgen drei verschiedene Reichweiten für denselben Angriff. Ob dich das stört, ob du Kreise nimmst oder die Rechtecke anders schneidest, entscheidest du; erst musst du den Unterschied kennen. + resources: + - title: Godot Docs · RectangleShape2D + url: https://docs.godotengine.org/en/stable/classes/class_rectangleshape2d.html + + - id: clip-kollision + type: practice + title: "Clip-Analyse: Größe oder Lage?" + prompt: | + Sieh dir Sakurais [Always Keep Attack Collision in Mind](https://www.youtube.com/watch?v=rwwF_4blK-o) an (2:41). Notiere zu drei Stellen, was du siehst: die Hitboxen, die größer sind als die Waffe (ab 0:16), das Beispiel für einen Unterschied in der Lage (ab 1:02) und den Schwertschlag, dessen Hitbox vor dem vollen Schwung beginnt (ab 1:55). Schreib danach einen Satz dazu, was „Lage“ in einem Spiel bedeutet, in dem ein Angriff in alle 360° zeigen kann. + lang: text + solution: | + Beispiel für die Form der Notiz: + + 0:16 Hitbox deckt mehr als die Faust ab, + Grund laut Video: schnelle Bewegung. + 1:02 Hitbox und Schlag an verschiedenen + Stellen: Er lässt die Animation ändern. + 1:55 Hitbox vor dem vollen Schwung, weil + der Hitstop diese Pose zeigt. + 360°: Die Lage ist die Richtung des Schlags + im Bild. Zeigt die Hitbox in eine + andere Richtung als der Arm, stimmt + die Lage nicht, egal wie groß sie ist. + checklist: + - Du hast alle drei Stellen notiert, jeweils mit dem, was du im Bild gesehen hast. + - Bei jeder Stelle steht, ob es um Größe, Lage oder Timing geht. + - Dein Satz zur 360°-Ausrichtung nennt, woran du einen Unterschied in der Lage erkennen würdest. + - Du hast notiert, an welcher Stelle du Sakurais Grenze selbst anders ziehen würdest oder warum nicht. + explanation: | + Die Übung trennt drei Arten von Abweichung, die in Gesprächen über „schlechte Hitboxen“ oft vermischt werden: Größe, Lage und Timing. Sakurai erlaubt die erste und die dritte bewusst und lehnt die zweite ab. In der Draufsicht kommt die Richtung dazu: Prüf, ob der Pivot die Hitbox dorthin dreht, wohin die Figur im Bild schlägt. Deine eigene Grenze darfst du anders ziehen; wichtig ist, dass du sie benennen kannst. + resources: + - title: Masahiro Sakurai · Always Keep Attack Collision in Mind + url: https://www.youtube.com/watch?v=rwwF_4blK-o + + - id: reichweite-notieren + type: practice + title: Reichweite eines eigenen Angriffs aufschreiben + prompt: | + Skizziere für einen Angriff, den du dir vorstellst, die Hitbox: Form, Größe und Lage relativ zur Figurenmitte. Nimm als Ziel einen neutralen Trainingsgegner mit einer Hurtbox, deren Größe du selbst festlegst. Rechne aus, bis zu welchem Mittenabstand der Angriff nach rechts und nach unten trifft, und wie viele Frames die Figur braucht, um 10 Pixel auf einen fliehenden Gegner aufzuholen. Schreib **vor** dem Test in der Szene auf, was du erwartest. Alle Werte sind Platzhalter, die du wählst. + lang: text + solution: | + Beispiel für die Form, nicht für die Werte. + Platzhalter der Testszene: + + Hitbox: Rechteck 14 × 10, Mitte 12 px voraus + Ziel: Hurtbox 12 × 16 + Reichweite: rechts 19 + 6 = 25 px + unten 19 + 8 = 27 px + Tempo: Figur 90 px/s, Gegner 60 px/s + Aufholen: 10 / 0,5 = 20 Frames + Erwartung: Nach unten trifft es spürbar + weiter, das merke ich beim Spielen. + Nach dem Test: Form, Größe oder Lage ändern? + checklist: + - Form, Größe und Lage der Hitbox stehen relativ zur Figurenmitte da, in Pixeln. + - Du hast die Reichweite für mindestens zwei Richtungen ausgerechnet und erklärt, warum sie sich unterscheidet oder nicht. + - Die Aufholzeit steht in Frames, mit Tempo in Pixel pro Frame als Zwischenschritt. + - Deine Erwartung stand fest, bevor du etwas getestet hast. + - Kein Wert ist als „richtig“ markiert; alle sind als Platzhalter gekennzeichnet. + explanation: | + Die Notiz ist die Form, in der du Flächen später an einen Coding-Agenten weitergibst: jede Fläche mit Form, Größe, Lage und Bezugspunkt. Die Rechnung zeigt dir vorab, wie Richtung, Zielgröße und Tempo die Reichweite verändern. Ob ein Rechteck oder ein Kreis besser zu deinem Angriff passt und wie weit er reichen soll, entscheidest du beim Spielen in der Testszene. Gibt es dort eine Abweichung zur Rechnung, prüfst du zuerst das Log und die Debug-Anzeige, dann deinen Wert. + resources: + - title: Godot Docs · Overview of debugging tools · Visible collision shapes + url: https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes diff --git a/content/combat/modules/spielgefuehl.yaml b/content/combat/modules/spielgefuehl.yaml new file mode 100644 index 0000000..830edd4 --- /dev/null +++ b/content/combat/modules/spielgefuehl.yaml @@ -0,0 +1,500 @@ +id: spielgefuehl +title: "Spielgefühl: Steuerung, Reaktion, Rückmeldung" +level: 1 +minutes: 75 +summary: Was Game Feel ist, wie Eingabe, Simulation und Rückmeldung einen Kreislauf bilden und warum dieser Bereich Spielgefühl in Frames misst – mit Messwerten veröffentlichter Spiele und Sakurais Zähltrick. +goals: + - Du erklärst Game Feel mit Swinks sechs Bestandteilen und ordnest Beispiele aus Actionspielen zu. + - Du beschreibst den Kreislauf aus Eingabe, Simulation und Rückmeldung und sagst, an welchen Stationen Zeit vergeht. + - Du rechnest Frames und Millisekunden bei 60 Frames pro Sekunde ineinander um und schätzt kurze Zeiten mit Sakurais Zähltrick. + - Du trennst Eingabelatenz und Zeit bis zum Treffer und belegst „reagiert sofort“ mit Messwerten veröffentlichter Spiele. + - Du begründest, warum Effekte eine späte Reaktion nicht beheben und warum der Kurs Frames misst statt Eindrücke zu beschreiben. +resources: + - title: Steve Swink · Game Feel – The Secret Ingredient + url: https://www.gamedeveloper.com/design/game-feel-the-secret-ingredient + - title: Masahiro Sakurai · Learn to Count Frames! + url: https://www.youtube.com/watch?v=JZ1Jd0u3b6U + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + - title: Jonasson & Purho · Juice it or lose it + url: https://www.youtube.com/watch?v=Fy0aCDmgnxg +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Stell dir zwei Fassungen desselben Schwertschlags vor. Beide treffen 9 Frames nach dem Druck, beide machen gleich viel Schaden. In der ersten springt die Figur im ersten Frame in eine Ausholpose. In der zweiten steht sie vier Frames still und holt erst dann aus. Auf dem Papier sind beide gleich schnell. Im Spiel siehst du bei der zweiten vier Frames lang nicht, ob dein Druck angekommen ist. Masahiro Sakurai, der Director von Super Smash Bros., baut Angriffe deshalb so, dass der erste Frame sofort eine Reaktion zeigt. + + Dieses Modul legt das Denkmodell für den ganzen Bereich. Es klärt, was Game Feel ist, wie Eingabe, Simulation und Rückmeldung zusammenspielen und warum du Spielgefühl in Frames misst statt in Adjektiven. Du hast eine Draufsicht im Kopf, analoge Bewegung in alle Richtungen und Pixelart. Welche Werte dein Spiel bekommt, entscheidest du. Hier lernst du, sie zu benennen, zu messen und zu vergleichen. + + ## Game Feel nach Swink: sechs Bestandteile + + Steve Swink beschreibt Game Feel als „the tactile sensation of manipulating a digital agent“, also das Gefühl in den Händen, wenn du eine Figur steuerst ([Game Feel: The Secret Ingredient](https://www.gamedeveloper.com/design/game-feel-the-secret-ingredient)). Damit man darüber reden kann, zerlegt er es in sechs Bestandteile. Die rechte Spalte überträgt sie auf ein Actionspiel in der Draufsicht: + + | Bestandteil | Was dazugehört | Beispiel | + | --- | --- | --- | + | Input | Ausdruck der Absicht | Stick, Angriffstaste | + | Response | Verarbeitung in Echtzeit | Tempo, Frames bis Treffer | + | Context | Raum und Hindernisse | Wände, Abstand zum Gegner | + | Polish | Animation, Ton, Effekte | Staub, Treffer-Blitz | + | Metaphor | was die Figur darstellt | ein Riese soll schwer wirken | + | Rules | Werte mit Bedeutung | Schaden, Lebenspunkte | + + Dieser Bereich arbeitet vor allem an Input, Response und Polish. Context und Rules kommen mit Gegnern und Bossen dazu. Die Metapher nimmt dir keine Zahl ab, sie beeinflusst aber, welche Zahlen passen. Swink schreibt ihr zu, dass sie Bewegung Bedeutung gibt und Vertrautheit schafft. Swinks Metapher fragt, ob Zahl und Bild zusammenpassen. Ob eine riesige Kreatur, die in 3 Frames zuschlägt, schwer wirkt, beurteilst du im Spiel. + + ## Der Kreislauf: Eingabe, Simulation, Rückmeldung + + Swink beschreibt, wie die Rückmeldung so schnell zurückkommt, dass er seine Eingaben laufend nachjustiert: „Feedback returns just as quickly, letting me adjust and fine-tune my instructions.“ Daraus ergibt sich ein Kreislauf, der viele Male pro Sekunde läuft: + + ```text + Eingabe Stick, Tasten + │ + ▼ + Simulation Zustand, Position, Treffer + │ + ▼ + Rückmeldung Bild, Ton, Vibration + │ + ▼ + Spieler sieht, entscheidet neu + │ + └──► nächste Eingabe + ``` + + Die **Simulation** ist, was das Spiel rechnet: In welchem Zustand ist die Figur, wo steht sie, trifft die Hitbox? In Godot läuft sie in `_physics_process`, fest 60-mal pro Sekunde ([Idle and Physics Processing](https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html)). Die **Rückmeldung** ist alles, was der Spieler davon wahrnimmt: Pose, Bewegung, Ton, Vibration. Swinks Response gehört zur Simulation, Polish zur Rückmeldung. + + Jede Station kostet Zeit. Der Druck braucht, bis das Spiel ihn sieht. Die Simulation entscheidet, in welchem Frame der Zustand wechselt. Das Bild muss gezeichnet und angezeigt werden. Wie lang diese Kette ist und was Godot dabei tut, steht in [Eingabe: Reaktionszeit, Latenz und Puffer](/combat/eingabe). Für das Denkmodell reicht: Game Feel entsteht im ganzen Kreislauf. Ein Angriff kann starke Effekte haben und trotzdem träge wirken, weil der Kreislauf an einer anderen Station hängt. + + ## Frames zählen: 60 pro Sekunde, 16,7 ms pro Frame + + Ein **Frame** ist in diesem Kurs ein Physik-Tick in Godot. Standard sind 60 pro Sekunde (`physics/common/physics_ticks_per_second = 60`), ein Frame dauert also 1000 / 60 ≈ 16,7 ms. Mit demselben Wert rechnet Sakurai in „Down with Lag!“ (etwa bei 4:43, [Clip](https://www.youtube.com/watch?v=gjoKKyskco4)). Die Umrechnung als kleines Modell, das du hier ausführen kannst: + + ```python run + FPS = 60 # Physik-Frames pro Sekunde + + def ms(frames): + return frames * 1000 / FPS + + def frames(millis): + return millis * FPS / 1000 + + for f in (1, 4, 8, 15): + print(f"{f} Frames = {ms(f):.1f} ms") + print(f"130 ms = {frames(130):.1f} Frames") + ``` + + Zwei Dinge zeigt das Modell. Kleine Frame-Zahlen sind große Unterschiede: 4 statt 8 Frames halbieren die Wartezeit. Und Millisekunden aus Vorträgen fallen selten auf ganze Frames: 130 ms sind 7,8 Frames. Übernimmst du so einen Wert, entscheidest du, ob daraus 7 oder 8 werden. + + Ohne Messwerkzeug hilft Sakurais Zähltrick aus „Learn to Count Frames!“ ([Clip](https://www.youtube.com/watch?v=JZ1Jd0u3b6U), [Kolumne als Fan-Übersetzung](https://sourcegaming.info/2018/05/18/learn-to-count-frames-sakurais-famitsu-column-vol-552/)). Zähl im Sekundentakt „1, 2, 3“ und setz ein „und“ dazwischen: „1-und-2-und-3“. Dann zählst du halbe Sekunden, jedes Wort dauert 30 Frames. Ein halbes Wort sind 15 Frames. So lange dauert es in Smash ungefähr, bis nach der Eingabe eines Smash-Angriffs die erste Hitbox erscheint. Bei normalen Schlägen und Tritten in Tekken ist es ähnlich, Street Fighter 2 liegt bei etwa der Hälfte. Sakurai geht es um ein Gespür: Wer weiß, wie lang 20 Frames sind, merkt beim Spielen, dass etwas nicht stimmt, und korrigiert es, bevor es zum Problem wird. + + Wie du innerhalb eines Angriffs zählst (Wind-up ab Frame 1, offizielle Startup-Werte mit dem ersten aktiven Frame), klärt [Anatomie eines Angriffs: Wind-up, aktiv, Erholung](/combat/angriffsphasen). + + ## „Reagiert sofort“ in Frames + + „Die Steuerung reagiert sofort“ kann zwei Zeitpunkte meinen. Die **Eingabelatenz** (Input Lag) ist die Zeit vom Druck bis zur ersten sichtbaren Änderung. Die **Zeit bis zum Treffer** reicht vom Druck bis zur aktiven Phase des Angriffs. Beide misst du in Frames, aber sie haben verschiedene Ursachen. + + Mick West hat 2008 die Eingabelatenz echter Spiele gemessen ([Measuring Responsiveness in Video Games](https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games)). Er filmte Controller und Bildschirm zugleich mit einer Kamera, die 60 Bilder pro Sekunde aufnimmt, und zählte die Bilder zwischen Druck und erster Reaktion: + + | Messung | Frames | ms | + | --- | --- | --- | + | PS3-Systemmenü | 3 | 50 | + | typisches 60-fps-Spiel | 4 | 67 | + | manche 30-fps-Spiele | 8–10 | 133–167 | + | GTA IV | 10 | 166 | + + 3/60 nennt er das Schnellste, was man auf der PS3 realistisch erwarten kann. Flachbildschirme legten etwa 2 Frames drauf. Sein Maßstab: Ein Spiel mit 60 fps sollte nicht über 4/60 liegen, eines mit 30 fps sollte 6/60 anstreben. + + Auch 2022 war das ein Thema. Dreamscaper ist ein Action-Roguelike in der Draufsicht von einem kleinen Team. Ian Cofino nennt dafür auf der Switch eine Eingabelatenz von etwa 130 ms, „like eight frames at 60fps“. Eine Option mit niedriger Latenz halbierte sie fast (ab etwa 21:01, [Dreamscaper: Killer Combat on an Indie Budget](https://www.youtube.com/watch?v=3Omb5exWpd4)). + + Die Zeit bis zum Treffer ist dagegen eine Designfrage. Ein Angriff darf ausholen, solange der Spieler vom ersten Frame an sieht, dass er ausholt. Sakurai lässt die Figur im ersten Frame in die Ausholpose springen, damit man sieht, dass die Eingabe angekommen ist ([Making Lead-ins Instant and Impactful](https://www.youtube.com/watch?v=E8DKndKkHw8), etwa 0:42–1:15); wie das mit Wind-up und Wucht zusammengeht, steht in [Anatomie eines Angriffs](/combat/angriffsphasen). + + > [!MERKE] + > Sofort reagieren heißt: Im ersten Frame nach dem Druck ändert sich sichtbar etwas. Wann der Angriff trifft, ist eine zweite Zahl. Miss beide getrennt. + + ## Juice verstärkt, ersetzt aber kein Timing + + **Juice** heißt die Schicht aus Effekten über der Simulation. Martin Jonasson und Petri Purho bauen in „Juice it or lose it“ ein schlichtes Breakout Schritt für Schritt aus ([Video](https://www.youtube.com/watch?v=Fy0aCDmgnxg)): Tweening und Easing (2:43), Squash and Stretch (5:34), Sound, den sie den kosteneffektivsten Schritt nennen (7:46), Partikel (9:31), Screenshake (12:03). Ihr Leitsatz heißt „maximum output for minimum input“ (1:24). Jan Willem Nijman von Vlambeer zeigt in „The art of screenshake“ 30 kleine Tricks an einem Shooter (ab 8:17, [Video](https://www.youtube.com/watch?v=AJdEqssNZ-U)). Darunter sind ein weißer Blitz beim Treffer (12:06), Gegner, die ein paar Pixel zurückweichen (12:36), und „sleep“: Beim Treffer hält das Spiel etwa 20 ms an (18:01). + + Juice wirkt auf die Rückmeldung. Er ändert nicht, wann die Simulation reagiert. Liegt die Eingabelatenz bei 8 Frames, wackelt der Bildschirm 8 Frames zu spät. Mark Brown, Kritiker hinter Game Maker's Toolkit, fasst zusammen, Juice solle verstärken, worum es im Spiel geht (4:36, [Secrets of Game Feel and Juice](https://www.youtube.com/watch?v=216_5nu4aVQ)). Für deine Arbeit heißt das: erst das Timing messen, dann die Rückmeldung bauen. Hitstop, Screenshake, Blitz und Knockback behandelt [Trefferfeedback](/combat/trefferfeedback). + + > [!WARNING] Effekte haben Kosten + > Nijman berichtet, dass Nuclear Throne eine Option brauchte, um Screenshake abzuschalten, weil manchen Spielern davon übel wurde (32:19). Wie stark ein Effekt sein darf, entscheidest du auch für Spieler, die ihn schlechter vertragen als du. + + ## Warum Werte zusammenhängen und der Kurs Frames misst + + Sakurai zeigt in „Giving "Weight" to Buttons“, dass dieselbe Taste schwerer oder leichter wirkt, je nach Tempo, Trägheit, Lag und Bildrate (0:47). Selbst die Deadzone des Analogsticks hat sein Team für jede Konsole neu abgestimmt (1:58, [Clip](https://www.youtube.com/watch?v=9qzUwoKT9T8)). Kein Wert wirkt allein. Ob 8 Frames Wind-up lang sind, hängt davon ab, wie schnell sich die Figur sonst bewegt und wie weit ein Gegner in 8 Frames kommt. Bei analoger 360°-Bewegung gehört auch die Deadzone dazu: Sie bestimmt, ab welchem Ausschlag die Figur überhaupt losläuft. + + Deshalb beschreibt dieser Kurs Spielgefühl in Messgrößen. Ein Wort wie „träge“ hat mehrere Ursachen, und jede hat ihre eigene Zahl: + + | Eindruck | mögliche Ursache | Messgröße | + | --- | --- | --- | + | „träge“ | späte erste Reaktion | Frames bis zur Pose | + | „träge“ | langer Wind-up | Frames bis aktiv | + | „verschluckt“ | Druck verfällt | Alter des Drucks | + | „schwammig“ | Beschleunigung | Frames bis volles Tempo | + | „kraftlos“ | wenig Rückmeldung | Hitstop, Knockback | + + Mit der Zahl kannst du vorhersagen, was eine Änderung bewirkt, das Ergebnis nachmessen und eine Vorgabe schreiben, nach der ein Coding-Agent baut, ohne selbst Werte zu erfinden ([Kampfmechanik als Vorgabe für einen Coding-Agenten](/combat/vorgaben)). Wie es sich anfühlt, beurteilst du danach selbst, am Controller. + + > [!PRAXIS] + > In Vorträgen von Studios stehen Zahlen, keine Adjektive. Cofino nennt für Dreamscaper die Latenz in Millisekunden und Frames, ebenso den Hitstop: „about 115 milliseconds like seven frames“ (36:42). So lässt sich ein Wert über Plattformen, Patches und Teammitglieder hinweg vergleichen. + + > [!INTERVIEW] + > „Ein Tester sagt, der Angriff fühle sich träge an. Was machst du?“ Gute Antwort: nachfragen und messen, bevor du etwas änderst. Kommt die erste sichtbare Reaktion spät, dauert der Wind-up lang, oder verfällt der Druck in der Erholung? Jede Ursache hat eine andere Zahl und eine andere Lösung. Dann änderst du eine Größe, sagst das Ergebnis vorher und testest wieder. + + ### Der Weg durch den Bereich + + - [Eingabe: Reaktionszeit, Latenz und Puffer](/combat/eingabe): wie schnell Menschen sind, wo Latenz entsteht, wie Puffer Drücke retten. + - [Anatomie eines Angriffs](/combat/angriffsphasen): Wind-up, aktive Phase, Erholung und Frame-Daten veröffentlichter Spiele. + - [Reichweite, Hitboxen und Hurtboxen](/combat/hitboxen): Trefferflächen in der Draufsicht. + - [Ausweichen, Risiko und Belohnung](/combat/ausweichen), [Gegnermuster und Telegraphing](/combat/gegnermuster) und [Große Kreaturen als Bosse](/combat/grosse-gegner): i-Frames, Telegraphs, Bosse. + - [Trefferfeedback](/combat/trefferfeedback) und [Schwer, unfair oder kaputt?](/combat/frust-diagnose): Rückmeldung und die Diagnose eines Treffers. + - [Kampfmechanik als Vorgabe für einen Coding-Agenten](/combat/vorgaben): Mechaniken so aufschreiben, dass ein Agent nur Code schreibt. + - Am PC: [Godot-Start](/combat/godot-start), die Testszene für [Angriff](/combat/testszene-angriff) und [Ausweichen](/combat/testszene-ausweichen), dann [Tuning als Experiment](/combat/tuning). + + ## Häufige Fehler + + - **Gefühl statt Zahl.** „Mach ihn knackiger“ ist kein Auftrag. Nenne Frames: erste Pose, aktive Phase, Erholung. + - **Eingabelatenz und Wind-up verwechselt.** Ein langer Wind-up mit sofortiger Pose ist etwas anderes als eine Figur, die nach dem Druck stillsteht. + - **Effekte gegen Trägheit.** Screenshake auf eine späte Reaktion kommt genauso spät. + - **Millisekunden ungerundet übernommen.** 20 ms sind 1,2 Frames. Die Testszene zählt ganze Frames, also entscheidest du, ob 1 oder 2. + - **Einen Wert allein abgestimmt.** Tempo, Trägheit, Latenz und Bildrate wirken zusammen. Ändere eine Größe und miss, was sich sonst verschiebt. + - **Videobilder als Frames gezählt.** Viele Clips laufen mit 30 Bildern pro Sekunde, ein Videobild sind dann 2 Frames. Prüf die Bildrate, bevor du zählst. + + ## Zusammenfassung + + - Game Feel ist nach Swink „the tactile sensation of manipulating a digital agent“, zerlegt in Input, Response, Context, Polish, Metaphor und Rules. + - Game Feel entsteht im Kreislauf aus Eingabe, Simulation und Rückmeldung. Jede Station kostet Zeit. + - Ein Frame ist ein Physik-Tick, 60 pro Sekunde, ≈ 16,7 ms. Millisekunden = Frames × 1000 / 60. + - Sakurais „1-und-2-und-3“: Ein Wort dauert 30 Frames, ein halbes 15. + - Eingabelatenz (Druck bis erste Änderung) und Zeit bis zum Treffer sind zwei Zahlen. Mick West: höchstens 4/60 bei 60 fps; Dreamscaper auf der Switch etwa 8 Frames. + - Der erste Frame zeigt eine Reaktion, auch wenn der Treffer später kommt. + - Juice verstärkt die Rückmeldung, verschiebt aber keinen Zeitpunkt der Simulation. + - Werte hängen zusammen. Deshalb misst du Frames, änderst eine Größe und vergleichst. +exercises: + - id: swink-bestandteil + type: choice + title: Blitz, Funken, Ton + prompt: | + Ein Schlag trifft. Der Gegner blinkt weiß, Funken sprühen, ein kurzer Ton spielt. Schaden, Zustände und Frames des Angriffs bleiben genau gleich. Zu welchem von Swinks sechs Bestandteilen gehört das? + options: + - text: Polish + correct: true + why: Swink meint damit das Zusammenspiel von Animation, Ton und Effekten mit der Bewegung. Hier kommt nur Rückmeldung dazu, die Simulation bleibt gleich. + - text: Response + correct: false + why: Response ist, wie das Spiel die Eingabe in Echtzeit verarbeitet, etwa Tempo oder Frames bis zum Treffer. Davon ändert sich hier nichts. + - text: Rules + correct: false + why: Rules sind Werte wie Schaden und Lebenspunkte, die Herausforderung und Bedeutung geben. Der Schaden bleibt gleich. + - text: Context + correct: false + why: Context ist der Raum mit seinen Hindernissen, der Bewegung Bedeutung gibt, etwa Wände oder Abstände. + explanation: | + Polish ist bei Swink der Eindruck von Körperlichkeit, der entsteht, wenn Animation, Ton und Effekte zur gesteuerten Bewegung passen. Ein Treffer-Blitz ändert keinen Zustand und keinen Frame, er ändert nur, was du wahrnimmst. Genau diese Trennung brauchst du später: Fühlt sich ein Treffer schwach an, kann es an Polish liegen oder an Response, und beides stimmst du an verschiedenen Stellen ab. + resources: + - title: Steve Swink · Game Feel – The Secret Ingredient + url: https://www.gamedeveloper.com/design/game-feel-the-secret-ingredient + + - id: sofort-belegt + type: choice + title: Was heißt „sofort“? + prompt: | + Du notierst über zwei Spiele: „Der Angriff reagiert sofort.“ Welche Beobachtungen belegen diese Aussage im Sinn der Lektion? + options: + - text: Im ersten Frame nach dem Druck springt die Figur in eine Ausholpose. Der Treffer kommt 9 Frames später. + correct: true + why: Die erste sichtbare Änderung kommt sofort. Dass der Treffer später kommt, ist eine zweite Zahl, die Zeit bis zum Treffer. + - text: Der Angriff trifft schon im ersten Frame nach dem Druck, wie der Jab von Zero Suit Samus, der in einem Frame herauskommt. + correct: true + why: Auch hier ändert sich im ersten Frame etwas. Sakurai warnt nur, dass einem Treffer ohne Wind-up Wucht fehlt. + - text: Beim Treffer wackelt der Bildschirm stark und der Gegner fliegt weit zurück. + correct: false + why: Das ist Rückmeldung beim Treffer. Über die ersten Frames nach dem Druck sagt sie nichts. + - text: Die Figur steht nach dem Druck 4 Frames still, trifft aber insgesamt nach 7 Frames. + correct: false + why: Die Zeit bis zum Treffer ist kurz, aber 4 Frames lang sieht der Spieler nicht, dass sein Druck angekommen ist. Die Eingabelatenz ist hier 4 Frames. + explanation: | + „Reagiert sofort“ bezieht sich auf die Eingabelatenz: die Zeit vom Druck bis zur ersten sichtbaren Änderung. Sakurai lässt die Figur deshalb im ersten Lead-in-Frame in die Pose springen, auch wenn der Treffer später kommt. Ob ein Angriff in 1 oder in 9 Frames trifft, ist eine eigene Designentscheidung. Effekte beim Treffer sagen über beides nichts. + resources: + - title: Masahiro Sakurai · Making Lead-ins Instant and Impactful + url: https://www.youtube.com/watch?v=E8DKndKkHw8 + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 + + - id: kreislauf-ordnen + type: order + title: Einmal durch den Kreislauf + prompt: | + Bring die Schritte in die Reihenfolge, in der sie ablaufen, von der Absicht bis zur nächsten Entscheidung. + lang: text + lines: | + Du willst dem Angriff ausweichen + Du drückst die Ausweichtaste + Das Spiel sieht den Druck im nächsten Physik-Frame + Die Simulation wechselt von MOVE nach DODGE + Das Bild zeigt die Figur in der Ausweichpose + Du siehst die Pose und lenkst mit dem Stick nach + explanation: | + Eingabe, Simulation, Rückmeldung, dann wieder der Spieler. Der Druck allein ändert nichts. Erst wenn das Spiel ihn liest, wechselt die Simulation den Zustand, und erst ein gezeichnetes Bild macht den Wechsel sichtbar. Der letzte Schritt schließt den Kreis: Du reagierst auf das, was du siehst, mit der nächsten Eingabe. Jeder Pfeil in dieser Kette kostet Zeit. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: dauern-sortieren + type: order + title: Von kurz nach lang + prompt: | + Sortiere die Zeiten von der kürzesten zur längsten. Rechne dafür alles in Millisekunden um (ein Frame ≈ 16,7 ms). + lang: text + lines: | + PS3-Systemmenü bei Mick West: 3/60 s + Typisches 60-fps-Spiel bei Mick West: 4/60 s + Dreamscaper auf der Switch: etwa 130 ms + GTA IV bei Mick West: 166 ms + Smash-Angriff bis zur ersten Hitbox: etwa 15 Frames + explanation: | + In Millisekunden: 50, 66,7, 130, 166 und 250. Die ersten vier sind Eingabelatenz, also die Zeit bis zur ersten Reaktion auf dem Bildschirm. Der letzte Wert ist eine Zeit bis zum Treffer, aus Sakurais Kolumne „Learn to Count Frames!“. Dass ein Smash-Angriff länger braucht als die langsamste Latenz in der Liste, ist kein Widerspruch: Ausholen ist gewollt, warten auf die erste Reaktion nicht. + resources: + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + - title: Source Gaming · Learn to Count Frames! (Sakurai, Famitsu Vol. 552) + url: https://sourcegaming.info/2018/05/18/learn-to-count-frames-sakurais-famitsu-column-vol-552/ + + - id: ms-umrechnen + type: output + title: Sechzigstel in Millisekunden + prompt: | + Mick West gibt Latenzen in Sechzigstelsekunden an. Was gibt das Programm aus? + lang: python + code: | + def ms(frames, fps=60): + return round(frames * 1000 / fps, 1) + + print(ms(3), ms(4), ms(4, fps=30)) + expected: | + 50.0 66.7 133.3 + accept: + - 50 66.7 133.3 + explanation: | + 3 × 1000 / 60 = 50 und 4 × 1000 / 60 ≈ 66,7. Der dritte Aufruf zeigt, was ein Spiel mit 30 Bildern pro Sekunde kostet: 4 seiner Bilder sind 133,3 ms, also 8/60. Genau in diesem Bereich lagen manche 30-fps-Spiele bei Mick West. `round(…, 1)` gibt eine Kommazahl zurück, deshalb steht da `50.0` und nicht `50`. + resources: + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + + - id: ganze-frames + type: output + title: 20 Millisekunden Stillstand + prompt: | + Vlambeer lässt das Spiel beim Treffer etwa 20 ms anhalten („sleep“). Die Testszene zählt Hitstop in ganzen Frames. Was gibt das Programm aus? + lang: python + code: | + import math + + def frames(millis, fps=60): + return millis * fps / 1000 + + f = frames(20) + print(round(f, 2), round(f), math.ceil(f)) + expected: | + 1.2 1 2 + explanation: | + 20 ms sind 1,2 Frames. Gerundet wird daraus 1, aufgerundet 2. Ein Spiel, das in festen Physik-Frames rechnet, kann keine 1,2 Frames anhalten. Wer einen Wert in Millisekunden aus einem Vortrag übernimmt, muss also entscheiden, ob er 16,7 oder 33,3 ms meint. Nijman sagt selbst „20 milliseconds or something“: Die Zahl ist ein Richtwert aus seinem Spiel, kein exakter Messwert. + resources: + - title: Jan Willem Nijman · The art of screenshake + url: https://www.youtube.com/watch?v=AJdEqssNZ-U + + - id: zaehltrick + type: gap + title: Sakurais Zähltrick + prompt: | + Ergänze die Notiz zu „1-und-2-und-3“. Gezählt wird so, dass jedes Wort eine halbe Sekunde dauert; 60 Frames pro Sekunde. + lang: text + code: | + ein Wort ⟦30⟧ Frames + halbes Wort ⟦15⟧ Frames = ⟦250⟧ ms + gaps: + - label: Frames pro Wort + hint: Eine halbe Sekunde bei 60 Frames pro Sekunde. + - label: Frames pro halbem Wort + hint: Die Hälfte davon. + - label: Millisekunden + hint: Ein Viertel einer Sekunde. + explanation: | + Eine halbe Sekunde sind 30 Frames, die Hälfte davon 15 Frames oder 250 ms. Laut Sakurai dauert es in Smash ungefähr so lange, bis nach der Eingabe eines Smash-Angriffs die erste Hitbox erscheint. Der Trick ersetzt keine Messung, aber er gibt dir eine Skala im Kopf: Was deutlich kürzer als ein halbes Wort ist, liegt unter 15 Frames. + resources: + - title: Masahiro Sakurai · Learn to Count Frames! + url: https://www.youtube.com/watch?v=JZ1Jd0u3b6U + + - id: messnotiz + type: gap + title: Eine Messung auswerten + prompt: | + Du hast einen Angriff in einem Spiel gefilmt und die Videobilder in Frames zu 1/60 s umgerechnet (die Zahlen sind für diese Übung ausgedacht). Ergänze die Notiz. + lang: text + code: | + Druck sichtbar Frame 100 + erste Pose Frame 103 + Treffer Frame 112 + Eingabelatenz ⟦3⟧ Frames + bis zum Treffer ⟦12⟧ Frames + bis zum Treffer ⟦200⟧ ms + gaps: + - label: Eingabelatenz + hint: Vom Druck bis zur ersten sichtbaren Änderung. + - label: Zeit bis zum Treffer in Frames + hint: Vom Druck bis zum Treffer. + - label: Zeit bis zum Treffer in ms + hint: Frames × 1000 / 60. + explanation: | + Die Eingabelatenz ist 103 − 100 = 3 Frames, die Zeit bis zum Treffer 112 − 100 = 12 Frames, also 12 × 1000 / 60 = 200 ms. Die drei Frames enthalten die ganze Kette, auch den Bildschirm; Mick West rechnet für Flachbildschirme etwa 2 Frames ein. Die neun Frames zwischen Pose und Treffer sind dagegen eine Entscheidung der Entwickler. + resources: + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + + - id: tabelle-ms + type: bug + title: Die Summe geht nicht auf + prompt: | + Du hast einen Angriff aus der Testszene in Millisekunden umgerechnet. Die Frames ergeben zusammen 25, die Millisekunden darüber aber nicht 417. Finde die falsche Zeile und korrigiere sie im Format der anderen. + lang: text + code: | + Angriff, 60 Frames pro Sekunde + Wind-up 8 Frames = 133 ms + aktiv 3 Frames = 50 ms + Erholung 14 Frames = 140 ms + gesamt 25 Frames = 417 ms + lines: [4] + fix: + - line: 4 + answers: + - Erholung 14 Frames = 233 ms + - Erholung 14 Frames = 233,3 ms + - Erholung 14 Frames = 233.3 ms + - Erholung 14 Frames ≈ 233 ms + explanation: | + 14 × 1000 / 60 ≈ 233 ms. In der Zeile steht 140, als wäre ein Frame 10 ms lang. Mit 233 stimmt die Summe: 133 + 50 + 233 = 416, mit Rundung 417. Die Zahlen sind die Platzhalter der Testszene. Rechne bei jeder Tabelle die Summe nach, ein Umrechnungsfehler fällt dort zuerst auf. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: erster-frame + type: explain + title: Pose sofort, Treffer später + prompt: | + Sakurai lässt die Figur im ersten Frame des Lead-ins in die Ausholpose springen, obwohl der Treffer erst später kommt. Erkläre in wenigen Sätzen, was das bewirkt und warum er den Wind-up trotzdem nicht streicht. + lang: text + code: | + Frame 1 Druck, Figur springt in die Pose + danach Wind-up, Figur holt weiter aus + später aktive Phase, Hitbox trifft + points: + - Der erste Frame zeigt dem Spieler, dass seine Eingabe angekommen ist; die Eingabelatenz bleibt kurz. + - Die Zeit bis zum Treffer ist eine eigene Größe und darf länger sein, ohne dass die Steuerung träge wirkt. + - Ohne Wind-up fehlt dem Treffer die Wucht; online kann der Wind-up außerdem Lag verdecken. + - Beides misst man getrennt in Frames, Druck bis Pose und Druck bis aktive Phase. + explanation: | + Sakurai trennt zwei Zeitpunkte. Der erste Frame beantwortet die Frage des Spielers „Hat das Spiel meinen Druck gesehen?“, deshalb springt die Figur sofort in eine deutlich andere Pose. Der Treffer kommt danach, und der Wind-up hat seinen eigenen Zweck: Er gibt dem Schlag Wucht, und im Online-Spiel kann er Verzögerung im Netz verdecken. Für die Abstimmung heißt das, zwei Zahlen zu messen statt einer. Dreamscaper nutzt dasselbe Muster, der Angriff springt dort ebenfalls sofort in die Ausholpose. + resources: + - title: Masahiro Sakurai · Making Lead-ins Instant and Impactful + url: https://www.youtube.com/watch?v=E8DKndKkHw8 + - title: Masahiro Sakurai · Breaking Down Attack Animations + url: https://www.youtube.com/watch?v=LewXWM7HDd8 + + - id: mehr-screenshake + type: explain + title: „Bau mehr Screenshake ein“ + prompt: | + Ein Freund testet einen Prototyp und sagt: „Der Angriff fühlt sich träge an. Bau mehr Screenshake ein.“ Du misst: Die erste sichtbare Änderung kommt 8 Frames nach dem Druck. Erkläre in wenigen Sätzen, warum mehr Screenshake das nicht behebt und was du als Nächstes prüfst. + lang: text + code: | + Messung, ausgedachtes Beispiel + Druck Frame 100 + erste Änderung Frame 108 + Treffer Frame 117 + Screenshake ab Frame 117 + points: + - Screenshake ist Rückmeldung; er ändert nicht, wann die Simulation auf den Druck reagiert. + - Er setzt erst beim Treffer ein, also nach der Eingabelatenz und dem Wind-up. + - 8 Frames sind 133 ms, doppelt so viel wie die 4/60, die Mick West für 60-fps-Spiele als Grenze nennt. + - Als Nächstes suchst du, wo die 8 Frames entstehen, und änderst erst danach eine Größe. + explanation: | + Der Eindruck „träge“ kommt hier aus der Eingabelatenz. Acht Frames, 133 ms, vergehen, bevor überhaupt etwas zu sehen ist. Screenshake kommt erst mit dem Treffer und damit noch später. Er kann einen Treffer stärker wirken lassen, aber er kann die ersten acht Frames nicht füllen. Bevor du etwas änderst, suchst du die Ursache: Steht die Figur nach dem Druck still, kommt der Druck spät im Spiel an, liegt es am Bildschirm? Juice gehört an die richtige Station des Kreislaufs. + resources: + - title: Mark Brown (GMTK) · Secrets of Game Feel and Juice + url: https://www.youtube.com/watch?v=216_5nu4aVQ + + - id: clip-lead-ins + type: practice + title: "Clip-Analyse: Making Lead-ins Instant" + prompt: | + Sieh dir Sakurais Clip „Making Lead-ins Instant and Impactful“ an (2:26). Halte an, wo es nötig ist. Notiere mit Zeitangaben: Was ändert sich im Moment des Drucks? Wie begründet Sakurai den Wind-up? Schreib zum Schluss eine Frage an dein eigenes Spiel auf, ohne sie schon zu beantworten. + lang: text + solution: | + Beispiel für die Form, nicht für + deine Antworten: + + Clip: Making Lead-ins Instant and Impactful + Gesehen: Handy, mit Ton, zweimal + + etwa 0:42–1:15 Im Moment des Drucks eine + große Posenänderung, + Reaktion zuerst. + 1:19 Zero Suit Samus: Jab in + einem Frame. + 1:45 Wind-up gibt dem Treffer + Wucht, kann online Lag + verdecken. + + Gemerkt: Pose und Treffer sind zwei + Zeitpunkte, ich messe beide. + Frage für später: Welche Pose zeigt meine + Figur im ersten Frame eines Angriffs? + checklist: + - Du hast den Clip ganz gesehen und mindestens drei Stellen mit Zeitangabe notiert. + - Du beschreibst, was im Moment des Drucks zu sehen ist, mit einer Pose oder Bewegung, nicht mit einem Adjektiv. + - Du nennst Sakurais Gründe für den Wind-up in eigenen Worten. + - Deine Notiz trennt Eingabelatenz und Zeit bis zum Treffer. + - Deine Frage an dein eigenes Spiel ist offen und noch nicht beantwortet. + explanation: | + Clip-Analysen trainieren das Hinsehen in Frames. Sakurai zeigt in gut zwei Minuten, dass Reaktion und Wucht kein Widerspruch sind, wenn man sie auf zwei Zeitpunkte verteilt. Die Frage am Ende ist Absicht: Du sammelst Fragen für dein Spiel, beantwortest sie aber erst, wenn du sie in der Testszene ausprobieren kannst. + resources: + - title: Masahiro Sakurai · Making Lead-ins Instant and Impactful + url: https://www.youtube.com/watch?v=E8DKndKkHw8 + + - id: erste-frames-beobachten + type: practice + title: Die ersten Frames in deinem Lieblingsspiel + prompt: | + Nimm ein Actionspiel, das du selbst spielst. Schreib auf, was du in den ersten Frames nach einem Angriffsdruck erwartest, bevor du hinsiehst. Dann filme Hand und Bildschirm mit der Zeitlupe deines Handys oder zähle mit „1-und-2-und-3“. Notiere: Wann siehst du die erste Änderung? Wann trifft der Angriff? Was zeigt das Spiel dazwischen? + lang: text + solution: | + Beispiel für die Form, ohne Werte: + + Spiel: ________ (Version, Gerät, + Bildschirm) + Messung: Zeitlupe ___ Bilder/s + Erwartung vorher: ________ + Druck Videobild ___ + erste Änderung Videobild ___ + Treffer Videobild ___ + Umrechnung: Videobilder × 60 / Rate + = Frames + Eingabelatenz ___ Frames + bis zum Treffer ___ Frames + Dazwischen zu sehen: ________ + Abweichung von der Erwartung: ________ + Mein Eindruck in einem Satz: ________ + checklist: + - Deine Erwartung stand auf dem Zettel, bevor du gemessen hast. + - Du hast mindestens drei Angriffe gemessen und nicht nur einen. + - Die Videobilder sind mit der Aufnahmerate in Frames zu 1/60 s umgerechnet. + - Du trennst Eingabelatenz (Druck bis erste Änderung) und Zeit bis zum Treffer. + - Du beschreibst, was zwischen Druck und Treffer zu sehen ist, ohne Wertung wie „gut“ oder „träge“. + - Dein Satz zum Eindruck ist deine Beobachtung; es gibt dafür keine richtige oder falsche Antwort. + explanation: | + Das ist Mick Wests Methode mit dem Handy: Controller und Bildschirm in einem Bild, Bilder zählen. Bei 240 Bildern pro Sekunde entsprechen 4 Videobilder einem Frame, bei 120 sind es 2. Dein Messwert enthält die ganze Kette, auch den Bildschirm, deshalb notierst du das Gerät. Die Zahlen gehören dem Spiel, das du misst, nicht deinem. Sie geben dir eine Vorstellung davon, wie sich bestimmte Frame-Zahlen anfühlen. + resources: + - title: Mick West · Measuring Responsiveness in Video Games + url: https://www.gamedeveloper.com/design/measuring-responsiveness-in-video-games + - title: Masahiro Sakurai · Learn to Count Frames! + url: https://www.youtube.com/watch?v=JZ1Jd0u3b6U diff --git a/content/combat/modules/testszene-angriff.yaml b/content/combat/modules/testszene-angriff.yaml new file mode 100644 index 0000000..7b0fdad --- /dev/null +++ b/content/combat/modules/testszene-angriff.yaml @@ -0,0 +1,887 @@ +id: testszene-angriff +title: "Testszene: Angriff mit Frame-Daten" +level: 2 +minutes: 180 +summary: Ein Angriff, dessen Wind-up, aktive Phase und Erholung als Zahlen im Inspektor stehen, ein Zustandsautomat, der Physik-Frames zählt, ein Eingabepuffer, ein Trainingsdummy mit Hurtbox, Hitstop und eine Anzeige, die jeden Treffer mit Frame-Nummer protokolliert. +goals: + - Du legst Frame-Daten als `Resource` an und änderst sie im Inspektor, ohne Code anzufassen. + - Du liest einen Zustandsautomaten, der Physik-Frames zählt, und sagst vorher, in welchem Frame ein Zustandswechsel im Log steht. + - Du erklärst Eingabepuffer und Abbruchfenster am Code und an echten Log-Zeilen. + - Du richtest Hitbox, Hurtbox und Kollisionsebenen so ein, dass nur gewollte Treffer zählen. + - Du unterscheidest lokalen Hitstop von `Engine.time_scale` und begründest, warum die Testszene den lokalen nimmt. +resources: + - title: Godot Docs · Resources + url: https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html + - title: Godot Docs · Physics introduction · Collision layers and masks + url: https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-layers-and-masks + - title: Godot Docs · Singletons (Autoload) + url: https://docs.godotengine.org/en/stable/tutorials/scripting/singletons_autoload.html + - title: Godot Docs · Engine.time_scale + url: https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-property-time-scale +lesson: | + > [!NOTE] Am PC + > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. + + Im [Godot-Start](/combat/godot-start) steckte das Timing des Angriffs in Keys einer Animation. Wer acht Frames Wind-up auf zehn ändern will, verschiebt dort zwei Keys und hofft, dass er keinen dritten vergessen hat. Zum Abstimmen willst du etwas anderes: Zahlen im Inspektor, einen Dummy zum Draufhauen und ein Log, das sagt, in welchem Frame was passiert ist. + + Dieses Modul baut genau das. Die Skripte darf dir eine KI schreiben, und hier stehen sie fertig. Lesen solltest du sie trotzdem: Wenn sich ein Treffer falsch anfühlt, musst du unterscheiden können, ob dein Wert nicht passt oder der Code etwas anderes tut, als du denkst. Alle Zahlen in den Skripten sind Platzhalter. Sie sind so gewählt, dass die Szene läuft, nicht so, dass sie sich gut anfühlt. Das Abstimmen ist deine Arbeit in [Tuning als Experiment](/combat/tuning). + + ## Frame-Daten als Resource + + Eine **Resource** ist in Godot ein Datenobjekt, das du im Inspektor bearbeitest und als `.tres`-Datei speichern kannst ([Resources](https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html)). Für einen Angriff ist das ideal: Die Figur bekommt ein Feld `attack`, und du ziehst verschiedene Angriffsdateien hinein, etwa `angriff_a.tres` und `angriff_b.tres` für einen Vergleich. + + ```gdscript title=attack_data.gd + class_name AttackData + extends Resource + + ## Frame-Daten eines Angriffs. Ein Frame ist ein Physik-Tick (60 pro Sekunde). + ## Die Startwerte sind willkürlich, damit die Szene läuft. Sie sind kein Vorschlag. + + @export_range(1, 60) var windup := 8 ## Frames vor der aktiven Phase + @export_range(1, 30) var active := 3 ## Frames, in denen die Hitbox trifft + @export_range(0, 60) var recovery := 14 ## Frames danach, bis die Figur wieder frei ist + @export_range(0, 60) var cancel_from := 0 ## Ab diesem Erholungs-Frame darf ein Anschluss starten (0 = nie) + @export var damage := 1 + @export_range(0, 20) var hitstop := 4 ## Frames, die Angreifer und Ziel beim Treffer anhalten + @export_range(0, 60) var hitstun := 12 ## Frames, die das Ziel danach handlungsunfähig ist + @export var knockback := 120.0 ## Pixel pro Sekunde, die das Ziel zurückgestoßen wird + + + func total() -> int: + return windup + active + recovery + ``` + + `class_name AttackData` macht den Typ im ganzen Projekt bekannt. `@export_range` begrenzt den Schieberegler im Inspektor; die Grenzen sind keine Empfehlung, sie verhindern nur Tippfehler wie 600 statt 6 ([@export_range](https://docs.godotengine.org/en/stable/classes/class_@gdscript.html#class-gdscript-annotation-export-range)). Alles ist in Frames angegeben, nur der Knockback in Pixeln pro Sekunde. + + > [!PRAXIS] Wo liegt die Wahrheit über das Timing? + > Entscheide pro Projekt, wer das Timing besitzt: die Animation oder der Code. Im Godot-Start war es die Animation. Hier zählt der Code die Frames, und eine Animation dürfte nur noch Bilder zeigen. Steuern beide die Hitbox, laufen sie beim ersten Tuning auseinander, und keiner weiß, welcher Wert gilt. Bleibt das Timing in der Animation, gehört `advance(0)` hinter jedes `play()`, sonst beginnt sie einen Frame zu spät. + + ## Ein Zustandsautomat, der Frames zählt + + Die Figur ist immer in genau einem Zustand: `MOVE`, `WINDUP`, `ACTIVE` oder `RECOVERY`. `frame` zählt, im wievielten Frame dieses Zustands sie gerade ist, beginnend bei 1. Jeder Aufruf von `_physics_process` ist ein Frame: + + ```gdscript + func _physics_process(_delta: float) -> void: + _read_buttons() + if hitstop_left > 0: + hitstop_left -= 1 + return + # 1. Übergänge: Ist der Zustand abgelaufen, oder startet eine Aktion? + frame += 1 + if state != State.MOVE and frame > _duration(state): + _enter(_next_state(state)) + if state == State.MOVE and _consume(&"attack"): + _enter(State.WINDUP) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + # 2. Verhalten im (neuen) Zustand + if state == State.MOVE: + _move() + if buffer_age >= 0: + buffer_age += 1 + ``` + + Der Ablauf hat zwei Phasen. Zuerst die **Übergänge**: `frame` rückt vor, und ist der Zustand abgelaufen, wechselt die Figur in den nächsten. Danach startet ein neuer Angriff, wenn die Figur frei ist. Dann das **Verhalten**: Nur in `MOVE` läuft die Figur. Wie lange ein Zustand dauert und was danach kommt, steht in zwei kleinen Funktionen: + + ```gdscript + func _duration(s: State) -> int: + match s: + State.WINDUP: return attack.windup + State.ACTIVE: return attack.active + State.RECOVERY: return attack.recovery + return 0 + + + func _next_state(s: State) -> State: + match s: + State.WINDUP: return State.ACTIVE + State.ACTIVE: return State.RECOVERY + return State.MOVE + + + func _enter(new_state: State) -> void: + HitLog.add("Spieler %s -> %s" % [State.keys()[state], State.keys()[new_state]]) + state = new_state + frame = 1 + # Hitbox nur in der aktiven Phase. set_deferred, weil Godot Formen + # während der Physik-Abfrage nicht umschalten lässt. + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + if new_state == State.ACTIVE: + already_hit.clear() + ``` + + Mit den Platzhaltern Wind-up 8, aktiv 3, Erholung 14 belegt der Angriff 25 Frames: Frame 1 bis 8 Wind-up, 9 bis 11 aktiv, 12 bis 25 Erholung. So steht es auch im Log, wenn kein Ziel in Reichweite ist. Die erste Spalte ist der Physik-Frame seit Spielstart: + + ```text + 12 Druck attack + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 23 Spieler ACTIVE -> RECOVERY + 37 Spieler RECOVERY -> MOVE + ``` + + Offizielle Frame-Daten wie die von Street Fighter 6 nennen den ersten aktiven Frame mit: Wind-up 8 heißt dort Startup 9 (siehe [Angriffsphasen](/combat/angriffsphasen)). Prüf bei jeder Tabelle, welche Zählung sie nutzt. + + ## Eingabepuffer und Abbruchfenster + + `is_action_just_pressed()` ist nur in dem einen Frame wahr, in dem die Taste runtergeht. Drückst du drei Frames vor dem Ende der Erholung, geht der Druck ohne Puffer verloren. Der Puffer merkt sich deshalb die Aktion und wie viele Frames der Druck alt ist: + + ```gdscript + func _read_buttons() -> void: + if Input.is_action_just_pressed(&"attack"): + HitLog.add("Druck attack") + buffered_action = &"attack" + buffer_age = 0 + + + func _consume(action: StringName) -> bool: + if buffered_action == action and buffer_age >= 0 and buffer_age <= input_buffer: + buffer_age = -1 + return true + return false + ``` + + `buffer_age` altert am Ende jedes Frames um eins, aber nicht im Hitstop, weil `_physics_process` dort vorher zurückkehrt. Ein Druck während des Hitstops bleibt also gültig. `input_buffer = 6` heißt: Ein Druck zählt, wenn die Figur höchstens 6 Frames später frei ist. Die Zeile `Druck attack` schreibt die Figur in dem Frame, in dem sie den Druck sieht. Zwei echte Läufe mit Treffer zeigen beide Fälle; die Erholung endet hier in Frame 41: + + ```text + 27 Spieler ACTIVE -> RECOVERY + 32 Druck attack + 41 Spieler RECOVERY -> MOVE + ``` + + ```text + 27 Spieler ACTIVE -> RECOVERY + 37 Druck attack + 41 Spieler RECOVERY -> MOVE + 41 Spieler MOVE -> WINDUP + ``` + + Der Druck aus Frame 32 ist in Frame 41 neun Frames alt und verfällt. Der aus Frame 37 ist vier Frames alt, und der zweite Angriff startet im ersten freien Frame. Warum Puffer überhaupt nötig sind und wie groß sie in veröffentlichten Spielen sind, steht in [Eingabe, Reaktionszeit und Puffer](/combat/eingabe). + + ### Abbruchfenster: Anschluss aus der Erholung + + `cancel_from` erlaubt einen neuen Angriff, bevor die Erholung vorbei ist. Mit `cancel_from = 6` darf ab dem sechsten Frame der Erholung ein gepufferter Angriff starten. Im echten Lauf beginnt die Erholung in Frame 27, der Druck kommt in Frame 31, und in Frame 32, dem sechsten Erholungsframe, startet der Anschluss: + + ```text + 27 Spieler ACTIVE -> RECOVERY + 31 Druck attack + 32 Spieler RECOVERY -> WINDUP + ``` + + Ob dein Spiel Anschlussangriffe hat, ab wann und ob nur nach einem Treffer, entscheidest du. Der Platzhalter `0` schaltet das Fenster ab. + + ## Treffer: Hitbox, Hurtbox und der Trainingsdummy + + Die **Hitbox** ist die Fläche, mit der ein Angriff trifft; die **Hurtbox** die Fläche, an der eine Figur verwundbar ist. Beide sind `Area2D`. Die Hurtbox bekommt ein kleines Skript. Es gibt jeden Treffer an seine Figur weiter, und die Figur entscheidet in `take_hit()`, ob er zählt: + + ```gdscript title=hurtbox.gd + class_name Hurtbox + extends Area2D + + ## Fläche, an der eine Figur getroffen werden kann. + ## Ob ein Treffer zählt, entscheidet die Figur (der Elternknoten) in take_hit(). + + @export var zone := &"body" + @export var damage_multiplier := 1.0 + + + func receive(attack: AttackData, source: Node2D) -> bool: + return get_parent().take_hit(attack, source, self) + ``` + + Damit nur gewollte Paare sich finden, benennst du unter *Projekteinstellungen → Ebenennamen → 2D-Physik* drei Ebenen und setzt Ebene (`collision_layer`) und Maske (`collision_mask`) so ([Collision layers and masks](https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html#collision-layers-and-masks)): + + | Node | Ebene | Maske | Einstellung | + | --- | --- | --- | --- | + | Hurtbox Spieler | 2 | – | `monitoring` aus | + | Hitbox Spieler | – | 3 | `monitorable` aus | + | Hurtbox Gegner | 3 | – | `monitoring` aus | + | Hitbox Gegner | – | 2 | `monitorable` aus | + + Ebene 1 heißt „Welt“ und bleibt für Wände. Die Regel aus der Doku: Ein Objekt A erkennt B nur, wenn B in einer Ebene liegt, die A scannt. Die Hitbox scannt also die Hurtboxen der Gegenseite und sonst nichts. Eine Hurtbox scannt selbst nichts (`monitoring` aus), muss aber gefunden werden können (`monitorable` an). + + Berührt die Hitbox eine Hurtbox, fragt sie über `receive()` nach. Nur wenn die getroffene Figur `true` zurückgibt, zählt der Treffer, und erst dann beginnt der Hitstop: + + ```gdscript + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox or area.get_parent() in already_hit: + return + if (area as Hurtbox).receive(attack, self): + already_hit.append(area.get_parent()) + hitstop_left = attack.hitstop + ``` + + `already_hit` sorgt dafür, dass ein Angriff jedes Ziel höchstens einmal trifft, auch wenn er zwei Hurtboxen desselben Gegners berührt. Ob ein Angriff mehrfach treffen soll, ist eine Designfrage. Die Regel hier ist nur der Startpunkt. Dafür muss jede Hurtbox ein direktes Kind ihrer Figur sein. Dass die Figur einen Treffer ablehnen kann, brauchst du im nächsten Modul: Während der i-Frames zählt kein Treffer. + + Eine Hitbox, die im Frame X angeht, meldet den Treffer im Frame X + 1. Im Log siehst du das als Abstand zwischen `WINDUP -> ACTIVE` und `getroffen`. + + ### Der Trainingsdummy + + Der Dummy nutzt dasselbe Skript wie die Trainingsgegner im nächsten Modul, nur ohne Angriffe. Er zählt Treffer, blinkt, wird zurückgestoßen und schreibt alles ins Log; das steht in `take_hit()` am Ende. Die Angriffslogik im oberen Teil liest du erst in [Testszene: Ausweichen und Gegner](/combat/testszene-ausweichen): + + ```gdscript title=enemy.gd + extends CharacterBody2D + + ## Trainingsgegner: wartet, kündigt den Angriff an (Telegraph), greift an, erholt sich. + ## Ohne Angriffe in „attacks“ ist er ein Dummy, der nur Treffer zählt. + + enum State { WAIT, TELEGRAPH, ACTIVE, RECOVERY, HITSTUN } + + @export var attacks: Array[AttackData] = [] ## Muster, wird der Reihe nach wiederholt + @export_range(1, 600) var wait_frames := 60 ## Pause vor jedem Angriff + @export var interruptible := true ## false: Treffer unterbrechen den Angriff nicht + + var state := State.WAIT + var frame := 1 + var pattern_index := 0 + var hitstop_left := 0 + var hitstun_frames := 0 + var hits_taken := 0 + + @onready var body: CanvasItem = $Body + @onready var telegraph: CanvasItem = $Telegraph + @onready var hitbox: Area2D = $Hitbox + @onready var hitbox_shape: CollisionShape2D = $Hitbox/CollisionShape2D + + + func _ready() -> void: + telegraph.visible = false + hitbox_shape.disabled = true + hitbox.area_entered.connect(_on_hitbox_area_entered) + + + func _physics_process(_delta: float) -> void: + if hitstop_left > 0: + hitstop_left -= 1 + return + frame += 1 + if frame > _duration(): + _enter(_next_state()) + if state == State.HITSTUN: + velocity = velocity.move_toward(Vector2.ZERO, 10.0) + move_and_slide() + + + func current_attack() -> AttackData: + return attacks[pattern_index] + + + func _duration() -> int: + match state: + State.WAIT: return wait_frames + State.TELEGRAPH: return current_attack().windup + State.ACTIVE: return current_attack().active + State.RECOVERY: return current_attack().recovery + State.HITSTUN: return hitstun_frames + return 0 + + + func _next_state() -> State: + match state: + State.WAIT: return State.TELEGRAPH if not attacks.is_empty() else State.WAIT + State.TELEGRAPH: return State.ACTIVE + State.ACTIVE: return State.RECOVERY + return State.WAIT + + + func _enter(new_state: State) -> void: + if new_state != state: + HitLog.add("%s %s -> %s" % [name, State.keys()[state], State.keys()[new_state]]) + if state == State.RECOVERY: + pattern_index = (pattern_index + 1) % attacks.size() + state = new_state + frame = 1 + telegraph.visible = new_state == State.TELEGRAPH + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + + + func _on_hitbox_area_entered(area: Area2D) -> void: + if area is Hurtbox and (area as Hurtbox).receive(current_attack(), self): + hitstop_left = current_attack().hitstop + + + func take_hit(attack: AttackData, source: Node2D, hurtbox: Hurtbox) -> bool: + hits_taken += 1 + HitLog.add("%s getroffen: Zone %s, Schaden %.1f, in %s Frame %d" % [name, hurtbox.zone, attack.damage * hurtbox.damage_multiplier, State.keys()[state], frame]) + hitstop_left = attack.hitstop + var flash := create_tween() + body.modulate = Color(3, 3, 3) + flash.tween_property(body, ^"modulate", Color.WHITE, 0.15) + if interruptible: + hitstun_frames = attack.hitstun + velocity = (global_position - source.global_position).normalized() * attack.knockback + _enter(State.HITSTUN) + return true + ``` + + Sein Baum: + + ```text + Dummy (CharacterBody2D) enemy.gd, motion_mode Floating + ├── Body (Polygon2D) 12 × 16 + ├── Telegraph (Polygon2D) gelbe Bodenfläche, erst im nächsten Modul + ├── Hurtbox (Area2D) hurtbox.gd, Ebene 3 + │ └── CollisionShape2D + └── Hitbox (Area2D) Maske 2 + └── CollisionShape2D disabled + ``` + + ## Hitstop: Angreifer und Ziel halten an + + Beim Treffer frieren Angreifer und Ziel für `hitstop` Frames ein. Beide setzen dafür ihren eigenen Zähler `hitstop_left`; solange er läuft, kehrt `_physics_process` sofort zurück. Der echte Lauf mit Treffer: + + ```text + 12 Druck attack + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body, Schaden 1.0, in WAIT Frame 21 + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + ``` + + Ohne Treffer endete die aktive Phase in Frame 23, jetzt in Frame 27: Die Frames 21 bis 24 standen still. Der Angriff dauert für den Spieler vier Frames länger, und für diese vier Frames kann er nichts anderes tun. Diese Kosten gehören zum Hitstop dazu (mehr in [Trefferfeedback](/combat/trefferfeedback)). + + Naheliegend wäre `Engine.time_scale = 0` für den Stillstand. Das hält aber nur an, was mit `delta` rechnet. `_physics_process` läuft weiter, nur mit `delta = 0`; mit Godot 4.7.2 gemessen: sechs Aufrufe in 0,1 Sekunden ([Engine.time_scale](https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-property-time-scale)). Ein Zustandsautomat, der Frames zählt, zählt also weiter, und der Angriff läuft im „Stillstand“ ab. Außerdem stünde alles still, auch Menüs und die Debug-Anzeige. + + ## Messen: Treffer-Log, Anzeige und Drehbuch + + Das Log ist ein **Autoload**: ein Node, den Godot beim Start einmal anlegt und der unter seinem Namen überall erreichbar ist ([Singletons (Autoload)](https://docs.godotengine.org/en/stable/tutorials/scripting/singletons_autoload.html)). Trag `hit_log.gd` unter *Projekteinstellungen → Globals → Autoload* mit dem Namen `HitLog` ein. + + ```gdscript title=hit_log.gd + extends Node + + ## Autoload „HitLog“: schreibt jedes Ereignis mit Physik-Frame in die Ausgabe + ## und hält die letzten Zeilen für die Debug-Anzeige bereit. + + const MAX_LINES := 8 + var lines: PackedStringArray = [] + + + func add(text: String) -> void: + var line := "%6d %s" % [Engine.get_physics_frames(), text] + print(line) + lines.append(line) + if lines.size() > MAX_LINES: + lines.remove_at(0) + ``` + + Die Anzeige im Bild ist ein `Label` in einem `CanvasLayer`, damit sie nicht mit der Kamera wandert. Im Inspektor ziehst du die Figur in das Feld `player` und stellst unter *Theme Overrides → Font Sizes* die Schriftgröße auf 8. Mit der Standardgröße 16 und acht Log-Zeilen wäre das Label über 500 Pixel breit, das Bild ist nur 320 breit; mit Größe 8, Statuszeile und drei Log-Zeilen misst es 257 × 57 Pixel (gemessen in Godot 4.7.2). Das vollständige Log steht weiter in der Ausgabe. + + ```gdscript title=debug_label.gd + extends Label + + ## Zeigt Zustand, Frame und Puffer der Spielfigur und die letzten drei Einträge aus HitLog. + ## Im Inspektor unter Theme Overrides → Font Sizes die Größe auf 8 stellen, + ## sonst ist die Anzeige breiter als das 320 × 180 Pixel große Bild. + + @export var player: CharacterBody2D + + + func _process(_delta: float) -> void: + var buffer := "leer" if player.buffer_age < 0 else "%s seit %d" % [player.buffered_action, player.buffer_age] + text = "%s Frame %d Puffer: %s\n%s" % [player.state_name(), player.frame, buffer, "\n".join(HitLog.lines.slice(-3))] + ``` + + Zusammen mit *Debug → Kollisionsformen sichtbar* und dem Einzelbild-Schritt (F10) siehst du jetzt bei jedem Treffer, welcher Zustand, welcher Frame und welcher Puffer im Spiel war. + + ### Eingaben nach Drehbuch + + Von Hand triffst du keinen Frame genau; bei 60 Frames pro Sekunde ist einer knapp 17 Millisekunden lang. Willst du einen Lauf wie in dieser Lektion wiederholen, drückt ein kleines Skript die Tasten für dich. Es ist Testgerüst, kein Teil des Spiels: + + ```gdscript title=drehbuch.gd + extends Node + + ## Drehbuch für Testläufe: drückt Aktionen in festen Physik-Frames, damit ein Lauf + ## frame-genau wiederholbar ist. Technisches Gerüst, kein Teil des Spiels. + ## Die Figur sieht einen Druck aus Frame N im Frame N + 1. + + @export var presses: Dictionary[int, StringName] = {} ## Physik-Frame -> Aktion + + + func _physics_process(_delta: float) -> void: + for action: StringName in [&"attack", &"dodge"]: + if Input.is_action_pressed(action): + Input.action_release(action) + var now := Engine.get_physics_frames() + if presses.has(now): + Input.action_press(presses[now]) + ``` + + Häng einen `Node` mit diesem Skript unter `Main` und stell im Inspektor *Process → Physics Priority* auf `-1`, damit er vor der Figur rechnet. Unter `presses` trägst du ein, in welchem Physik-Frame welche Aktion gedrückt wird. Die Figur sieht den Druck einen Frame später: Mit `11: attack` und `36: attack` steht im Log `12 Druck attack` und `37 Druck attack`, genau die Läufe von oben (geprüft mit Godot 4.7.2). Für wiederholbare Läufe startest du die Szene aus dem Terminal mit fester Bildrate, etwa `godot --path . --fixed-fps 60`. + + > [!INTERVIEW] + > „Ein Tester sagt, der Angriff komme manchmal nicht raus. Wie gehst du vor?“ Gute Antwort: Erst messen, dann ändern. Ein Log mit Frame-Nummer für jeden Tastendruck und jeden Zustandswechsel zeigt, ob der Druck in der Erholung kam und verfiel, ob er im Hitstop lag oder gar nicht ankam. Erst dann entscheidest du, ob der Puffer zu kurz ist oder die Erholung zu lang – und das ist eine Designentscheidung, keine Fehlerbehebung. + + ## Die Szene und das Skript der Spielfigur zum Kopieren + + So sieht die Hauptszene dieses Moduls aus. Positionen und Größen sind die, mit denen die Logs der Lektion entstanden sind: + + ```text + Main (Node2D) + ├── Player (CharacterBody2D) player.gd, Position (80, 90), Floating + │ ├── Body (Polygon2D) 12 × 12 + │ ├── CollisionShape2D 10 × 10, Ebene 1 + │ ├── Hurtbox (Area2D) hurtbox.gd, Ebene 2, monitoring aus + │ │ └── CollisionShape2D 10 × 10 + │ └── HitboxPivot (Node2D) + │ └── Hitbox (Area2D) Maske 3, monitorable aus + │ └── CollisionShape2D 14 × 10 bei (12, 0), disabled + ├── Dummy (CharacterBody2D) enemy.gd, Position (100, 90), Floating + │ ├── Body (Polygon2D) 12 × 16 + │ ├── Telegraph (Polygon2D) 14 × 10 bei (−16, 0), wie die Hitbox + │ ├── Hurtbox (Area2D) hurtbox.gd, Ebene 3, monitoring aus + │ │ └── CollisionShape2D 12 × 16 + │ └── Hitbox (Area2D) Maske 2, monitorable aus + │ └── CollisionShape2D 14 × 10 bei (−16, 0), disabled + ├── DebugLayer (CanvasLayer) + │ └── DebugLabel (Label) debug_label.gd, Schrift 8, player = Player + └── Drehbuch (Node) drehbuch.gd, nur für Testläufe + ``` + + Die Spielfigur hat also gegenüber dem Godot-Start eine `Hurtbox` mit eigenem Skript und eigener Form und keinen `AnimationPlayer` mehr. Ins Feld `attack` legst du im Inspektor *Neues AttackData* an und speicherst es als `angriff_a.tres`. + + ```gdscript title=player.gd + extends CharacterBody2D + + ## Spielfigur der Testszene: Bewegung und ein Angriff mit Frame-Daten. + ## Ein Frame ist ein Physik-Tick. Alle Startwerte sind Platzhalter zum Abstimmen. + + enum State { MOVE, WINDUP, ACTIVE, RECOVERY } + + @export var move_speed := 90.0 ## Pixel pro Sekunde + @export var attack: AttackData + @export_range(0, 20) var input_buffer := 6 ## so viele Frames bleibt ein Tastendruck gültig + + var state := State.MOVE + var frame := 1 # Frame im aktuellen Zustand, ab 1 + var facing := Vector2.RIGHT + var buffered_action := &"" + var buffer_age := -1 # Frames seit dem gepufferten Druck, -1 = leer + var hitstop_left := 0 + var already_hit: Array[Node] = [] + + @onready var hitbox_pivot: Node2D = $HitboxPivot + @onready var hitbox: Area2D = $HitboxPivot/Hitbox + @onready var hitbox_shape: CollisionShape2D = $HitboxPivot/Hitbox/CollisionShape2D + + + func _ready() -> void: + hitbox_shape.disabled = true + hitbox.area_entered.connect(_on_hitbox_area_entered) + + + func _physics_process(_delta: float) -> void: + _read_buttons() + if hitstop_left > 0: + hitstop_left -= 1 + return + # 1. Übergänge: Ist der Zustand abgelaufen, oder startet eine Aktion? + frame += 1 + if state != State.MOVE and frame > _duration(state): + _enter(_next_state(state)) + if state == State.MOVE and _consume(&"attack"): + _enter(State.WINDUP) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + # 2. Verhalten im (neuen) Zustand + if state == State.MOVE: + _move() + if buffer_age >= 0: + buffer_age += 1 + + + func _read_buttons() -> void: + if Input.is_action_just_pressed(&"attack"): + HitLog.add("Druck attack") + buffered_action = &"attack" + buffer_age = 0 + + + func _consume(action: StringName) -> bool: + if buffered_action == action and buffer_age >= 0 and buffer_age <= input_buffer: + buffer_age = -1 + return true + return false + + + func _move() -> void: + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + if direction != Vector2.ZERO: + facing = direction.normalized() + hitbox_pivot.rotation = facing.angle() + velocity = direction * move_speed + move_and_slide() + + + func _duration(s: State) -> int: + match s: + State.WINDUP: return attack.windup + State.ACTIVE: return attack.active + State.RECOVERY: return attack.recovery + return 0 + + + func _next_state(s: State) -> State: + match s: + State.WINDUP: return State.ACTIVE + State.ACTIVE: return State.RECOVERY + return State.MOVE + + + func _enter(new_state: State) -> void: + HitLog.add("Spieler %s -> %s" % [State.keys()[state], State.keys()[new_state]]) + state = new_state + frame = 1 + # Hitbox nur in der aktiven Phase. set_deferred, weil Godot Formen + # während der Physik-Abfrage nicht umschalten lässt. + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + if new_state == State.ACTIVE: + already_hit.clear() + + + func state_name() -> String: + return State.keys()[state] + + + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox or area.get_parent() in already_hit: + return + if (area as Hurtbox).receive(attack, self): + already_hit.append(area.get_parent()) + hitstop_left = attack.hitstop + ``` + + ## Häufige Fehler + + - **Wind-up einen Frame zu kurz.** `frame >= _duration(state)` statt `>`: Der Zustand endet schon im letzten Frame. Das Log zeigt sieben statt acht Frames zwischen `MOVE -> WINDUP` und `WINDUP -> ACTIVE`. + - **Frames in `_process` gezählt.** Dann hängt die Länge eines Angriffs an der Bildrate. + - **Hitbox im Treffer-Signal direkt umgeschaltet.** Godot lehnt das ab; `set_deferred()` nehmen. + - **Treffer kommen nie an.** Hurtbox mit `monitorable` aus, falsche Ebene, oder die Hurtbox ist kein direktes Kind der Figur (dann findet `receive()` kein `take_hit()`). + - **Hitstop mit `Engine.time_scale = 0`.** Frame-Zähler laufen weiter, und das ganze Spiel steht, auch Anzeigen. + - **Puffer altert im Hitstop.** Steht `buffer_age += 1` vor der Hitstop-Prüfung, verfällt ein Druck, während das Bild stillsteht. + - **Animation und Code schalten beide die Hitbox.** Eine Quelle für das Timing festlegen. + + ## Zusammenfassung + + - Frame-Daten liegen in einer `AttackData`-Resource. Du änderst sie im Inspektor und speicherst Varianten als `.tres`. + - Der Zustandsautomat zählt Physik-Frames ab 1: erst Übergänge, dann Verhalten. Wind-up 8, aktiv 3, Erholung 14 ergibt 25 Frames. + - Der Puffer merkt Aktion und Alter des Drucks. Er altert nicht im Hitstop. + - `cancel_from` öffnet ein Abbruchfenster in der Erholung; `0` heißt nie. + - Hitbox scannt die Hurtbox-Ebene der Gegenseite. Treffer kommen einen Frame nach dem Einschalten. + - Lokaler Hitstop hält genau Angreifer und Ziel an und verlängert ihre Zustände. `Engine.time_scale` hält keine Frame-Zähler an. + - `HitLog` und die Debug-Anzeige machen jeden Treffer mit Frame-Nummer nachvollziehbar. +exercises: + - id: quelle-des-timings + type: choice + title: Animation oder Code? + prompt: | + Du willst Wind-up und aktive Phase oft ändern und Varianten vergleichen. Welche Aussagen stimmen? + options: + - text: Zählt der Code die Frames, änderst du einen Wert im Inspektor, und alles Weitere folgt daraus. + correct: true + why: Die Dauer jedes Zustands kommt aus `AttackData`. Keine Keys müssen mitwandern. + - text: Varianten als `.tres`-Dateien lassen sich im Inspektor austauschen, ohne den Code zu ändern. + correct: true + why: Eine Resource ist eine Datei. Das Feld `attack` nimmt jede `AttackData` an. + - text: Am sichersten ist es, wenn die Animation und der Code beide die Hitbox schalten, dann fällt ein Fehler sofort auf. + correct: false + why: Zwei Quellen laufen beim ersten Tuning auseinander. Dann gilt mal die eine, mal die andere, je nachdem, wer zuletzt schreibt. + - text: Mit Frame-Zählung im Code braucht der Angriff keine Animation mehr. + correct: false + why: Sehen soll der Spieler den Angriff trotzdem. Die Animation zeigt Bilder, nur das Timing der Hitbox kommt aus dem Code. + explanation: | + Für das Abstimmen zählt, dass ein Wert an genau einer Stelle steht. Liegt das Timing im Code, steht es in der Resource; die Animation folgt dem Zustand und zeigt nur Bilder. Liegt es in der Animation, gilt die Zeitleiste. Beides funktioniert. Gefährlich ist nur, beides gleichzeitig zu tun. + resources: + - title: Godot Docs · Resources + url: https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html + + - id: frame-ablauf + type: order + title: Was in einem Frame passiert + prompt: | + Bring die Schritte von `_physics_process` der Spielfigur in die Reihenfolge, in der sie in einem Frame ablaufen. + lang: text + lines: | + Tastendrücke lesen und neuen Druck merken + Im Hitstop: Zähler verringern und den Frame beenden + frame um eins erhöhen + Ist der Zustand abgelaufen, in den nächsten wechseln + Ist die Figur frei und ein Angriff gepuffert, den Angriff starten + In MOVE die Figur bewegen + Den gepufferten Druck einen Frame älter machen + explanation: | + Tasten liest die Figur zuerst, auch im Hitstop, damit kein Druck verloren geht. Der Hitstop beendet den Frame früh, deshalb rückt in dieser Zeit weder `frame` noch `buffer_age` vor. Danach kommen erst die Übergänge (Zustand abgelaufen, neue Aktion) und dann das Verhalten im neuen Zustand. Am Ende altert der Puffer. Würde die Figur erst laufen und dann den Angriff starten, machte sie im ersten Frame des Angriffs noch einen Schritt. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: aktiv-ab-frame + type: output + title: Wann geht die Hitbox an? + prompt: | + Ein Angriff hat Wind-up 10, aktiv 4, Erholung 16. Das Log zeigt `200 Spieler MOVE -> WINDUP`. In welchem Physik-Frame steht `Spieler WINDUP -> ACTIVE`? Antworte mit der Zahl. + lang: text + code: | + 200 Spieler MOVE -> WINDUP + ??? Spieler WINDUP -> ACTIVE + expected: | + 210 + explanation: | + Frame 200 ist der erste Wind-up-Frame. Wind-up 10 belegt die Frames 200 bis 209, in Frame 210 beginnt die aktive Phase. Allgemein: Wechsel nach `ACTIVE` bei Startframe plus Wind-up. Mit Godot 4.7.2 nachgemessen. + resources: + - title: Street Fighter 6 · Frame-Daten (Begriffe Startup, Active, Recovery) + url: https://www.streetfighter.com/6/character/ryu/frame + + - id: erholung-mit-hitstop + type: output + title: Der Treffer verlängert den Angriff + prompt: | + Derselbe Angriff (Wind-up 10, aktiv 4, Erholung 16) mit Hitstop 5 trifft den Dummy im ersten aktiven Frame. In welchem Frame steht `Spieler ACTIVE -> RECOVERY`? + lang: text + code: | + 200 Spieler MOVE -> WINDUP + 210 Spieler WINDUP -> ACTIVE + 211 Dummy getroffen: Zone body, Schaden 1.0, in WAIT … + ??? Spieler ACTIVE -> RECOVERY + expected: | + 219 + hints: + - Ohne Treffer wäre es Frame 214. + - Der Treffer kommt in Frame 211 an. Ab da stehen fünf Frames still. + explanation: | + Die Hitbox ist ab Frame 210 an, der Treffer kommt einen Frame später, in 211, und setzt den Hitstop auf 5. Die Frames 211 bis 215 stehen still. Danach zählt die aktive Phase weiter: 216, 217 und 218 sind ihre Frames 2 bis 4, in 219 beginnt die Erholung. Ohne Treffer wäre es 214. Mit Godot 4.7.2 nachgemessen. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: puffer-reicht + type: output + title: Kommt der zweite Angriff? + prompt: | + Die Figur ist ab Frame 230 wieder frei (`RECOVERY -> MOVE`). `input_buffer` ist 6. Du drückst `attack`, und `_read_buttons()` sieht den Druck in Frame 225. In welchem Frame steht `Spieler MOVE -> WINDUP`? + lang: text + code: | + 225 Druck attack + 230 Spieler RECOVERY -> MOVE + ??? Spieler MOVE -> WINDUP + expected: | + 230 + explanation: | + In Frame 225 ist der Druck 0 Frames alt, in Frame 230 fünf. Das ist höchstens `input_buffer`, also startet der Angriff im ersten freien Frame, 230. Hätte die Figur den Druck in Frame 222 gesehen, wäre er in 230 acht Frames alt und verfallen. + resources: + - title: Godot Docs · Input.is_action_just_pressed + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-is-action-just-pressed + + - id: puffer-bedingung + type: gap + title: Wann zählt ein gepufferter Druck? + prompt: | + Ergänze die Bedingung: Ein Druck zählt, wenn er zur Aktion passt, überhaupt vorhanden ist und höchstens `input_buffer` Frames alt ist. + lang: gdscript + code: | + func _consume(action: StringName) -> bool: + if buffered_action == action and ⟦buffer_age >= 0⟧ and ⟦buffer_age <= input_buffer⟧: + buffer_age = -1 + return true + return false + gaps: + - label: Ein Druck ist gemerkt + hint: Ohne Druck ist `buffer_age` −1. + accept: ["buffer_age > -1", "buffer_age != -1", "0 <= buffer_age"] + - label: Nicht zu alt + hint: Alter in Frames gegen den Puffer. + accept: ["input_buffer >= buffer_age"] + explanation: | + `buffer_age` ist −1, solange nichts gemerkt ist, sonst das Alter in Frames. Die Obergrenze ist inklusiv: Bei `input_buffer = 0` zählt ein Druck nur im selben Frame, bei 6 bis zu sechs Frame alt. Danach setzt `_consume` das Alter auf −1, damit ein Druck nicht zwei Aktionen auslöst. + resources: + - title: Godot Docs · Input.is_action_just_pressed + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-is-action-just-pressed + + - id: hitbox-nur-aktiv + type: gap + title: Die Hitbox folgt dem Zustand + prompt: | + Ergänze die Zeile in `_enter()`, die die Hitbox genau in der aktiven Phase einschaltet. Sie muss auch funktionieren, wenn `_enter()` aus einem Treffer-Signal aufgerufen wird. + lang: gdscript + code: | + func _enter(new_state: State) -> void: + state = new_state + frame = 1 + hitbox_shape.⟦set_deferred⟧(&"disabled", ⟦new_state != State.ACTIVE⟧) + gaps: + - label: Umschalten, ohne die Physik-Abfrage zu stören + hint: Die Methode von `Object`, die eine Eigenschaft am Ende der laufenden Abfrage setzt. + - label: Wann ist die Hitbox aus? + hint: "`disabled` ist wahr in jedem Zustand außer einem." + accept: ["not new_state == State.ACTIVE", "State.ACTIVE != new_state"] + explanation: | + `disabled` ist das Gegenteil von „aktiv“, deshalb `new_state != State.ACTIVE`. Weil jeder Zustandswechsel durch diese Zeile läuft, kann die Hitbox nicht versehentlich anbleiben. `set_deferred()` braucht es, weil `_enter()` im nächsten Modul auch aus Treffer-Signalen kommt, und dort lehnt Godot direkte Änderungen an Formen ab. + resources: + - title: Godot Docs · CollisionShape2D.disabled + url: https://docs.godotengine.org/en/stable/classes/class_collisionshape2d.html#class-collisionshape2d-property-disabled + + - id: wind-up-zu-kurz + type: bug + title: Acht eingestellt, sieben gemessen + prompt: | + `windup` steht auf 8. Im Log liegen zwischen `MOVE -> WINDUP` (Frame 12) und `WINDUP -> ACTIVE` (Frame 19) aber nur sieben Frames. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + _read_buttons() + if hitstop_left > 0: + hitstop_left -= 1 + return + frame += 1 + if state != State.MOVE and frame >= _duration(state): + _enter(_next_state(state)) + lines: [7] + fix: + - line: 7 + answers: + - "if state != State.MOVE and frame > _duration(state):" + - "if frame > _duration(state) and state != State.MOVE:" + - "if state != State.MOVE and _duration(state) < frame:" + - "if state != State.MOVE and frame >= _duration(state) + 1:" + explanation: | + `frame` beginnt bei 1. Im achten Wind-up-Frame ist `frame` 8, und mit `>=` endet der Zustand schon dort; der Wechsel steht dann im Log einen Frame zu früh. Mit `>` endet er erst, wenn `frame` 9 erreicht, also nach genau acht Frames. Off-by-one-Fehler wie dieser verschieben alle Frame-Daten um eins, und niemand merkt es, bis jemand das Log nachzählt. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: hitstop-time-scale + type: bug + title: Stillstand, der keiner ist + prompt: | + Du baust den Hitstop um. Beim Treffer steht das Bild jetzt still, aber im Log endet die aktive Phase so früh, als gäbe es keinen Hitstop. Finde die Zeile, die das verursacht, und ersetze sie so, dass der lokale Hitstop der Testszene greift. + lang: gdscript + code: | + func _start_hitstop(frames: int) -> void: + Engine.time_scale = 0.0 + await get_tree().create_timer(frames / 60.0, true, false, true).timeout + Engine.time_scale = 1.0 + lines: [2] + fix: + - line: 2 + answers: + - hitstop_left = frames + - self.hitstop_left = frames + explanation: | + `Engine.time_scale = 0` skaliert `delta` auf 0, aber `_physics_process` wird weiter aufgerufen. Mit Godot 4.7.2 gemessen: sechs Aufrufe in 0,1 Sekunden. Was mit `delta` rechnet, steht still; der Frame-Zähler des Zustandsautomaten läuft weiter. Mit `hitstop_left = frames` kehrt `_physics_process` während des Hitstops sofort zurück. Die Zeilen 3 und 4 sind danach überflüssig und können weg. + resources: + - title: Godot Docs · Engine.time_scale + url: https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-property-time-scale + + - id: zwei-phasen-erklaeren + type: explain + title: Erst Übergänge, dann Verhalten + prompt: | + Erkläre, warum `_physics_process` erst den Zustand weiterschaltet und einen neuen Angriff startet und erst danach die Figur bewegt. Was ginge schief, wenn `_move()` ganz am Anfang stünde? + lang: gdscript + code: | + # 1. Übergänge: Ist der Zustand abgelaufen, oder startet eine Aktion? + frame += 1 + if state != State.MOVE and frame > _duration(state): + _enter(_next_state(state)) + if state == State.MOVE and _consume(&"attack"): + _enter(State.WINDUP) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + # 2. Verhalten im (neuen) Zustand + if state == State.MOVE: + _move() + points: + - Der Zustand für diesen Frame steht erst nach den Übergängen fest; das Verhalten soll zum aktuellen Zustand passen. + - Startet ein Angriff, bewegt sich die Figur in diesem Frame schon nicht mehr; der erste Wind-up-Frame ist ein Frame ohne Schritt. + - Endet die Erholung, kann die Figur im selben Frame schon laufen oder einen gepufferten Angriff starten, ohne einen Frame zu verlieren. + - Stünde `_move()` vorn, liefe die Figur im ersten Angriffsframe noch einen Schritt und stünde im ersten freien Frame still. + explanation: | + Die Reihenfolge legt fest, in welchem Zustand die Figur einen Frame verbringt. Erst wird entschieden: Ist der Zustand abgelaufen, startet eine Aktion? Dann handelt die Figur so, wie es zum neuen Zustand passt. So belegt ein Angriff genau Wind-up plus aktiv plus Erholung Frames, und der erste freie Frame ist wirklich frei. Mit `_move()` am Anfang wäre jede Aktion um einen Frame verschoben. Das wäre klein, aber messbar, und es würde jede Frame-Tabelle verfälschen. + + - id: ein-treffer-pro-ziel + type: explain + title: Warum already_hit? + prompt: | + Erkläre, was `already_hit` verhindert und warum die Hurtbox dafür ein direktes Kind ihrer Figur sein muss. + lang: gdscript + code: | + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox or area.get_parent() in already_hit: + return + if (area as Hurtbox).receive(attack, self): + already_hit.append(area.get_parent()) + hitstop_left = attack.hitstop + points: + - Berührt ein Angriff mehrere Hurtboxen desselben Ziels, zählt nur der erste angenommene Treffer; ohne die Liste träfe er zweimal. + - Die Liste wird beim Eintritt in `ACTIVE` geleert, jeder neue Angriff darf also wieder treffen. + - Gemerkt wird die Figur über `get_parent()`; liegt die Hurtbox tiefer im Baum, wäre der Elternknoten nicht die Figur, und zwei Zonen zählten als zwei Ziele. + - Ob ein Angriff mehrfach treffen soll, ist eine Designentscheidung; die Liste ist nur die Startregel der Testszene. + explanation: | + Ein großer Gegner hat später mehrere Hurtboxen, etwa Kopf und Bein. Ohne `already_hit` würde ein breiter Schlag, der beide berührt, doppelt zählen. Die Liste merkt sich deshalb die Figur, nicht die Hurtbox, und sie findet die Figur über den Elternknoten. Geleert wird sie in `_enter()` beim Eintritt in die aktive Phase. Wenn du Mehrfachtreffer willst, etwa bei einem Wirbelangriff, änderst du genau diese Regel. + resources: + - title: Godot Docs · Area2D.area_entered + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-signal-area-entered + + - id: testszene-bauen + type: practice + title: Die Testszene mit Dummy bauen + prompt: | + Bau die Szene dieses Moduls: Spielfigur mit `player.gd`, Hurtbox und Hitbox, ein Dummy mit `enemy.gd`, `HitLog` als Autoload, Debug-Anzeige. Starte die Szene, triff den Dummy einmal und vergleiche dein Log mit dem in der Lektion. + lang: text + solution: | + Log mit den Platzhalterwerten (Godot 4.7.2): + + 12 Druck attack + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body, Schaden 1.0, in WAIT Frame 21 + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + + Die absoluten Frame-Nummern hängen davon ab, wann du drückst. + Die Abstände müssen gleich sein: 8 Frames bis ACTIVE, Treffer + 1 Frame danach, RECOVERY 7 Frames nach ACTIVE, MOVE 14 danach. + checklist: + - Die Ebenen 1 bis 3 heißen „Welt“, „Hurtbox Spieler“, „Hurtbox Gegner“, und Ebenen und Masken stehen wie in der Tabelle. + - "`HitLog` ist als Autoload eingetragen, und die Ausgabe zeigt jede Zeile mit Frame-Nummer." + - Mit den Platzhalterwerten stimmen die Abstände im Log mit der Lektion überein (8 Frames Wind-up, Treffer einen Frame später, aktive Phase 7 Frames inklusive Hitstop, 14 Frames Erholung). + - Die Debug-Anzeige zeigt Zustand, Frame und Puffer, und du hast mit F10 einen Angriff Frame für Frame angesehen. + - "Mit dem Drehbuch (`11: attack`, `36: attack`) startet der zweite Angriff in Frame 41; mit `31: attack` statt `36` verfällt der zweite Druck." + explanation: | + Stimmen die Abstände, verhält sich deine Szene wie die geprüfte. Weichen sie ab, liegt es fast immer an einer von drei Stellen: `>=` statt `>` beim Zustandsende, einer Hurtbox, die nicht direkt unter ihrer Figur hängt, oder einer falschen Maske. Das Log ist ab jetzt dein wichtigstes Werkzeug. Jede Frage, warum etwas getroffen hat oder nicht, beantwortest du zuerst dort. + resources: + - title: Godot Docs · Singletons (Autoload) + url: https://docs.godotengine.org/en/stable/tutorials/scripting/singletons_autoload.html + + - id: erste-vorhersage + type: practice + title: Erst vorhersagen, dann messen + prompt: | + Leg im Dateisystem-Dock eine Kopie `angriff_b.tres` von `angriff_a.tres` an und wähle dort selbst einen neuen Wert für `windup` oder `recovery`. Schreib **vor** dem Start auf, in welchen Frames die Zustandswechsel im Log stehen werden. Dann starte und vergleiche. Notiere zum Schluss in einem Satz, wie sich der Angriff mit dem neuen Wert anfühlt. + lang: text + solution: | + Beispiel für die Form, nicht für den Wert: + + Änderung: recovery 14 -> 20 (Platzhalter gegen Platzhalter) + Vorhersage ohne Treffer, Start in Frame S: + WINDUP -> ACTIVE S + 8 + ACTIVE -> RECOVERY S + 11 + RECOVERY -> MOVE S + 31 + Gemessen (S = 140): 148, 151, 171 – stimmt. + (Diese Zahlen sind in Godot 4.7.2 nachgemessen.) + Eindruck: Nach dem Schlag stehe ich spürbar länger, ich will + danach öfter laufen, als ich darf. + checklist: + - Die Vorhersage stand fest, bevor du die Szene gestartet hast. + - Du hast die Abweichung zwischen Vorhersage und Log erklärt oder festgestellt, dass es keine gibt. + - Der Satz zum Eindruck beschreibt, was du beim Spielen bemerkt hast, nicht, ob der Wert „richtig“ ist. + - Der neue Wert steht als eigene Datei (`angriff_b.tres`), die alte ist unverändert. + explanation: | + Die Übung trennt zwei Fragen, die beim Abstimmen gern verschwimmen: Tut die Szene, was du denkst? Das beantwortet das Log. Und wie fühlt es sich an? Das beantwortest nur du. Wer die erste Frage nicht vorher klärt, beurteilt bei der zweiten womöglich einen Fehler statt eines Werts. Welchen Wert du wählst, ist egal; es geht um die Gewohnheit, vorherzusagen und zu messen. Die zweite Datei brauchst du für Vergleichstests in [Tuning als Experiment](/combat/tuning). diff --git a/content/combat/modules/testszene-ausweichen.yaml b/content/combat/modules/testszene-ausweichen.yaml new file mode 100644 index 0000000..d76eb96 --- /dev/null +++ b/content/combat/modules/testszene-ausweichen.yaml @@ -0,0 +1,733 @@ +id: testszene-ausweichen +title: "Testszene: Ausweichen und Trainingsgegner" +level: 2 +minutes: 180 +summary: Die Figur bekommt ein Ausweichen mit i-Frames, der Dummy einen angekündigten Angriff, und ein großer Trainingsgegner mit zwei Trefferzonen kommt dazu. Unterwegs findest du einen Fehler, der nur mit i-Frames auftritt, und liest an Logs ab, wer nach einer Aktion zuerst wieder handeln kann. +goals: + - Du baust ein Ausweichen mit einstellbaren i-Frames und sagst vorher, in welchem Frame ein Treffer ankommt oder ausbleibt. + - Du erklärst, warum die Testszene die Hurtbox während der i-Frames abschaltet, statt Treffer nur abzulehnen. + - Du richtest einen Trainingsgegner mit Telegraph und einem Muster aus mehreren Angriffen im Inspektor ein. + - Du liest aus einem Log ab, wie viele Frames ein Punish-Fenster nach dem Ausweichen offen ist. + - Du baust einen großen Trainingsgegner mit mehreren Hurtbox-Zonen und schaltest seine Unterbrechbarkeit um. +resources: + - title: Godot Docs · Area2D (monitorable, area_entered) + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html + - title: Godot Docs · GDScript exported properties + url: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html + - title: GDC 2021 · Melee AI in The Last of Us Part II (Folien) + url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf +lesson: | + > [!NOTE] Am PC + > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. + + Bisher hält der Dummy still. Jetzt schlägt er zurück, und die Figur bekommt ein Ausweichen. Damit kommen die Fragen, um die es in [Ausweichen, Risiko und Belohnung](/combat/ausweichen) geht, in deine Szene: Ab welchem Frame schützt das Ausweichen? Wann bin ich wieder verwundbar? Und wie lange bleibt mir danach, um zurückzuschlagen? + + Das Modul baut auf [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) auf. `enemy.gd`, `hurtbox.gd`, `attack_data.gd` und `hit_log.gd` bleiben, wie sie sind; `player.gd` ersetzt du am Ende durch die neue Fassung. Alle Zahlen sind wieder Platzhalter. + + ## Ausweichen als eigener Zustand + + Das Ausweichen bekommt zwei Zustände: `DODGE`, in dem die Figur sich schnell bewegt, und `DODGE_RECOVERY`, in dem sie danach kurz stillsteht. Die Werte stehen in einer eigenen Gruppe im Inspektor ([@export_group](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html#grouping-exports)): + + ```gdscript + @export_group("Ausweichen") + @export_range(1, 60) var dodge_frames := 18 + @export_range(1, 60) var invulnerable_from := 2 ## erster unverwundbarer Frame des Ausweichens + @export_range(1, 60) var invulnerable_to := 12 ## letzter unverwundbarer Frame + @export var dodge_speed := 220.0 + @export_range(0, 60) var dodge_recovery := 8 + ``` + + Die Richtung liest die Figur aus dem Stick, wenn das Ausweichen startet; mit Puffer kann das bis zu `input_buffer` Frames nach dem Druck sein. Ohne Richtung weicht die Figur in Blickrichtung aus. So geht das Ausweichen in alle 360°: + + ```gdscript + func _enter(new_state: State) -> void: + HitLog.add("Spieler %s -> %s" % [State.keys()[state], State.keys()[new_state]]) + state = new_state + frame = 1 + # Hitbox nur in der aktiven Phase. set_deferred, weil _enter auch aus Treffer-Signalen + # kommt und Godot Formen während der Physik-Abfrage nicht umschalten lässt. + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + match new_state: + State.WINDUP: + velocity = Vector2.ZERO + State.ACTIVE: + already_hit.clear() + State.DODGE: + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + dodge_direction = direction.normalized() if direction != Vector2.ZERO else facing + ``` + + Mit den Platzhaltern dauert das Ausweichen 18 Frames, die Frames 2 bis 12 sind unverwundbar, danach folgen 8 Frames Erholung. Bei 220 Pixeln pro Sekunde legt die Figur `220 × 18 / 60 = 66` Pixel zurück; in Godot 4.7.2 gemessen: 65,99998 Pixel, der Rest ist Rundung von Gleitkommazahlen. Ein echter Lauf ohne Gegner: + + ```text + 12 Druck dodge + 12 Spieler MOVE -> DODGE + 13 Spieler i-Frames an (Frame 2 des Ausweichens) + 24 Spieler i-Frames aus (Frame 13 des Ausweichens) + 30 Spieler DODGE -> DODGE_RECOVERY + 38 Spieler DODGE_RECOVERY -> MOVE + ``` + + Ob dein Ausweichen eine verwundbare Anlaufphase hat, wie weit es trägt und ob es eine Erholung braucht, entscheidest du. Hades 2 zeigt, dass die Werte zusammenhängen: Ein Upgrade machte den Dash sofort und schneller, und die Patchnotes warnen, dass die Figur dadurch „very slightly less time“ unverwundbar ist ([Hades 2 Patch 1](https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1)). + + ## i-Frames: Die Hurtbox schaltet ab + + Die erste Idee für i-Frames liegt nahe: `take_hit()` gibt während der i-Frames `false` zurück, und der Treffer zählt nicht. Das funktioniert, solange kein Angriff noch aktiv ist, wenn die i-Frames enden. Im folgenden Lauf hat der Dummy einen Angriff mit 20 aktiven Frames, und die Figur weicht auf der Stelle aus (`dodge_speed = 0`, nur für den Test): + + ```text + 28 Druck dodge + 28 Spieler MOVE -> DODGE + 30 Dummy TELEGRAPH -> ACTIVE + 31 Spieler weicht aus (Frame 3 des Ausweichens) + 46 Spieler DODGE -> DODGE_RECOVERY + 50 Dummy ACTIVE -> RECOVERY + ``` + + Die i-Frames enden in Frame 40. Bis Frame 49 steht die Figur verwundbar in der aktiven Hitbox, und nichts passiert. Der Grund: `area_entered` meldet nur das **Hineinkommen**. Die Figur war schon in Frame 31 drin, also gibt es keine zweite Meldung ([Area2D](https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-signal-area-entered)). + + Die Testszene schaltet deshalb die Hurtbox selbst ab. `monitorable = false` nimmt sie aus der Physik, gegnerische Hitboxen finden sie nicht mehr. Geht sie nach den i-Frames wieder an, erkennt die Physik die Überlappung neu und meldet sie: + + ```gdscript + func is_invulnerable() -> bool: + return state == State.DODGE and frame >= invulnerable_from and frame <= invulnerable_to + + + ## i-Frames schalten die Hurtbox ab. Wird sie wieder eingeschaltet, während eine + ## gegnerische Hitbox noch aktiv ist, meldet die Physik die Überlappung neu. + func _update_hurtbox() -> void: + var off := is_invulnerable() + if off == hurtbox_off: + return + hurtbox_off = off + hurtbox.set_deferred(&"monitorable", not off) + HitLog.add("Spieler i-Frames %s (Frame %d des Ausweichens)" % ["an" if off else "aus", frame]) + ``` + + Derselbe Lauf mit dieser Fassung: + + ```text + 28 Druck dodge + 28 Spieler MOVE -> DODGE + 29 Spieler i-Frames an (Frame 2 des Ausweichens) + 30 Dummy TELEGRAPH -> ACTIVE + 40 Spieler i-Frames aus (Frame 13 des Ausweichens) + 41 Spieler getroffen in DODGE, Frame 13 + ``` + + Wer zu früh ausweicht und stehen bleibt, wird jetzt getroffen, wenn die i-Frames enden. Geprüft wird `is_invulnerable()` in `take_hit()` trotzdem noch; doppelt hält hier besser. + + > [!WARNING] monitorable, nicht monitoring + > `monitoring` schaltet ab, ob eine Fläche **selbst** etwas erkennt. Die Hurtbox erkennt ohnehin nichts. Wer hier `monitoring` umschaltet, ändert nichts, und der lange Angriff trifft wieder nie. Gemessen mit Godot 4.7.2. + + ### Zu spät oder gerade noch: ein Frame im Log + + Der Dummy hat für diese Läufe einen Angriff mit 20 Frames Telegraph, 4 aktiv und 30 Erholung. Die Figur steht in Reichweite. Einen Frame zu spät gedrückt: + + ```text + 10 Dummy WAIT -> TELEGRAPH + 30 Druck dodge + 30 Spieler MOVE -> DODGE + 30 Dummy TELEGRAPH -> ACTIVE + 31 Spieler getroffen in DODGE, Frame 1 + ``` + + Die Hitbox geht in Frame 30 an, der Treffer kommt in Frame 31 an. Zu diesem Zeitpunkt ist die Figur im ersten Frame des Ausweichens, und der ist nicht unverwundbar. Einen Frame früher, im letzten möglichen Frame: + + ```text + 29 Druck dodge + 29 Spieler MOVE -> DODGE + 30 Spieler i-Frames an (Frame 2 des Ausweichens) + 30 Dummy TELEGRAPH -> ACTIVE + 34 Dummy ACTIVE -> RECOVERY + 41 Spieler i-Frames aus (Frame 13 des Ausweichens) + ``` + + Kein Treffer: In Frame 30 gehen die Hitbox an und die Hurtbox aus, der Physik-Schritt findet nichts. Und weil kein Treffer zählte, gibt es auch keinen Hitstop: Die aktive Phase des Dummys endet nach genau 4 Frames. Der Unterschied ist ein Frame, knapp 17 Millisekunden. Genau hier entscheidet sich, ob sich ein Ausweichen fair anfühlt, und deshalb brauchst du das Log: Aus dem Spielgefühl allein weißt du nicht, ob du zu spät warst oder ob der Code einen Frame verschluckt hat (siehe [Schwer, unfair oder kaputt?](/combat/frust-diagnose)). + + ## Der Trainingsgegner: Telegraph, Angriff, Erholung + + Der Dummy war schon ein Gegner ohne Angriffe. Gib ihm im Inspektor unter `attacks` eine oder mehrere `AttackData`, und er arbeitet sie der Reihe nach ab. Der Wind-up einer gegnerischen `AttackData` ist sein **Telegraph**: Solange er läuft, zeigt `Telegraph` eine gelbe Fläche am Boden. + + ```gdscript + func _physics_process(_delta: float) -> void: + if hitstop_left > 0: + hitstop_left -= 1 + return + frame += 1 + if frame > _duration(): + _enter(_next_state()) + if state == State.HITSTUN: + velocity = velocity.move_toward(Vector2.ZERO, 10.0) + move_and_slide() + + + func current_attack() -> AttackData: + return attacks[pattern_index] + + + func _duration() -> int: + match state: + State.WAIT: return wait_frames + State.TELEGRAPH: return current_attack().windup + State.ACTIVE: return current_attack().active + State.RECOVERY: return current_attack().recovery + State.HITSTUN: return hitstun_frames + return 0 + + + func _next_state() -> State: + match state: + State.WAIT: return State.TELEGRAPH if not attacks.is_empty() else State.WAIT + State.TELEGRAPH: return State.ACTIVE + State.ACTIVE: return State.RECOVERY + return State.WAIT + + + func _enter(new_state: State) -> void: + if new_state != state: + HitLog.add("%s %s -> %s" % [name, State.keys()[state], State.keys()[new_state]]) + if state == State.RECOVERY: + pattern_index = (pattern_index + 1) % attacks.size() + state = new_state + frame = 1 + telegraph.visible = new_state == State.TELEGRAPH + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + + ``` + + Ein voller Zyklus mit `wait_frames = 10` und dem Platzhalterangriff: + + ```text + 10 Dummy WAIT -> TELEGRAPH + 30 Dummy TELEGRAPH -> ACTIVE + 34 Dummy ACTIVE -> RECOVERY + 64 Dummy RECOVERY -> WAIT + 74 Dummy WAIT -> TELEGRAPH + ``` + + Zeichne den `Telegraph` so groß wie die Hitbox des Angriffs, dann zeigt die Fläche genau, wo es trifft. Naughty Dog beschreibt für The Last of Us Part II denselben Schritt: Früher galt „Enemy starts swinging = certain hit“, für das neue Ausweichen wurden die Ankündigungen länger und deutlicher ([Melee AI in The Last of Us Part II](https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf)). Wie lang deine Telegraphs sind, ob sie die Fläche genau zeigen oder nur andeuten, entscheidest du. Das Muster aus mehreren Angriffen behandelt [Gegnermuster und Telegraphing](/combat/gegnermuster). + + ## Punish-Fenster: Wer ist zuerst wieder frei? + + Ein Log zeigt nicht nur, ob getroffen wurde, sondern auch, wer danach zuerst handeln kann. Ein Lauf mit Druck in Frame 28 geht so weiter: + + ```text + 34 Dummy ACTIVE -> RECOVERY + 46 Spieler DODGE -> DODGE_RECOVERY + 54 Spieler DODGE_RECOVERY -> MOVE + 64 Dummy RECOVERY -> WAIT + ``` + + Die Figur ist ab Frame 54 frei, der Dummy erholt sich bis Frame 63. Das sind 10 Frames, in denen ein Angriff von dir ihn in der Erholung trifft: ein **Punish-Fenster**. Mit 8 Frames Wind-up und Start in Frame 54 geht deine Hitbox in Frame 62 an, und der Treffer kommt in Frame 63, im letzten Erholungsframe. Auch ein Start in Frame 55 trifft noch: Die Meldung aus Frame 64 kommt am Anfang des Frames, bevor der Dummy seine Erholung verlässt, und das Log schreibt „in RECOVERY Frame 30“. Das alles gilt nur, wenn du in Reichweite stehst. In diesem Lauf stehst du 66 Pixel weiter, denn das Ausweichen hat dich an ihm vorbeigetragen. + + Diese Rechnung hängt an vier Werten: Erholung des Gegners, Länge deines Ausweichens samt Erholung, dein Wind-up und die Strecke. Ändere einen, und das Fenster ändert sich. Das ist der Kern von Risiko und Belohnung, und deshalb stimmst du diese Werte später als Experiment ab, nicht einzeln nach Gefühl. + + ## Der große Trainingsgegner: Zonen und Unterbrechbarkeit + + Für große Kreaturen bekommt derselbe `enemy.gd` einen größeren Körper, eine große Hitbox und mehrere Hurtboxen, alle als direkte Kinder: + + ```text + BigEnemy (CharacterBody2D) enemy.gd, interruptible aus + ├── Body (Polygon2D) 48 × 48 + ├── Telegraph (Polygon2D) 64 × 40 unter dem Körper + ├── Kopf (Area2D) hurtbox.gd, zone kopf, damage_multiplier 2 + │ └── CollisionShape2D 24 × 16 + ├── Bein (Area2D) hurtbox.gd, zone bein, damage_multiplier 1 + │ └── CollisionShape2D 16 × 20 + └── Hitbox (Area2D) Maske 2 + └── CollisionShape2D 64 × 40, disabled + ``` + + Ein echter Lauf mit einem langsamen Platzhalterangriff (40 Frames Telegraph, 6 aktiv, 50 Erholung). Die Figur trifft zweimal das Bein, einmal im Warten und einmal in der Erholung: + + ```text + 12 Druck attack + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 BigEnemy getroffen: Zone bein, Schaden 1.0, in WAIT Frame 21 + 34 BigEnemy WAIT -> TELEGRAPH + 74 BigEnemy TELEGRAPH -> ACTIVE + 80 BigEnemy ACTIVE -> RECOVERY + 81 BigEnemy getroffen: Zone bein, Schaden 1.0, in RECOVERY Frame 1 + ``` + + Zwei Dinge sieht man hier. Erstens: Mit `interruptible = false` wechselt der Gegner nach dem Treffer nicht in `HITSTUN`, er setzt sein Muster fort. Das ist eine Designentscheidung. Den Hitstop bekommt er trotzdem, deshalb beginnt sein Telegraph erst in Frame 34 statt 30. Zweitens: Der Stampfer trifft die Figur nicht. Sie steht neben der großen Fläche, und das Log zeigt das: Es gibt keine Zeile „Spieler getroffen“. Ob der Kopf von der Seite erreichbar ist, welche Zone mehr Schaden nimmt und wann, sind Fragen für deinen eigenen großen Gegner (siehe [Große Kreaturen als Bosse](/combat/grosse-gegner)). + + > [!PRAXIS] + > Stimm Gegner gegen feste Spielerwerte ab. Ändere nie Spielerwerte und Gegnerwerte im selben Testlauf. Sonst weißt du nicht, welche Änderung das Punish-Fenster verschoben hat. + + > [!INTERVIEW] + > „Wie stellst du sicher, dass ein Ausweichen gegen alle Angriffe fair ist?“ Gute Antwort: Ich lege für jeden Angriff den Telegraph, die aktive Phase und die Fläche fest und prüfe im Log, aus welchen Druck-Frames das Ausweichen gelingt. Lange aktive Phasen teste ich ausdrücklich mit „zu früh ausweichen und stehen bleiben“, weil dort Fehler in der Treffererkennung sichtbar werden. Die Grenzen dieser Frames sind dann eine bewusste Entscheidung. + + ## Das neue Skript der Spielfigur zum Kopieren + + Ersetze `player.gd` durch diese Fassung. Neu sind die Ausweich-Werte, die Zustände `DODGE`, `DODGE_RECOVERY` und `HITSTUN`, `_update_hurtbox()` und `take_hit()`. Die Figur braucht dafür einen Hurtbox-Node namens `Hurtbox` (Ebene 2), den sie seit dem letzten Modul schon hat, und die Aktion `dodge` in der Input Map. + + ```gdscript title=player.gd + extends CharacterBody2D + + ## Spielfigur der Testszene: Bewegung, ein Angriff mit Frame-Daten, Ausweichen. + ## Ein Frame ist ein Physik-Tick. Alle Startwerte sind Platzhalter zum Abstimmen. + + enum State { MOVE, WINDUP, ACTIVE, RECOVERY, DODGE, DODGE_RECOVERY, HITSTUN } + + @export var move_speed := 90.0 ## Pixel pro Sekunde + @export var attack: AttackData + @export_range(0, 20) var input_buffer := 6 ## so viele Frames bleibt ein Tastendruck gültig + + @export_group("Ausweichen") + @export_range(1, 60) var dodge_frames := 18 + @export_range(1, 60) var invulnerable_from := 2 ## erster unverwundbarer Frame des Ausweichens + @export_range(1, 60) var invulnerable_to := 12 ## letzter unverwundbarer Frame + @export var dodge_speed := 220.0 + @export_range(0, 60) var dodge_recovery := 8 + + var state := State.MOVE + var frame := 1 # Frame im aktuellen Zustand, ab 1 + var facing := Vector2.RIGHT + var dodge_direction := Vector2.RIGHT + var buffered_action := &"" + var buffer_age := -1 # Frames seit dem gepufferten Druck, -1 = leer + var hitstop_left := 0 + var hitstun_frames := 0 + var already_hit: Array[Node] = [] + var hurtbox_off := false # true während der i-Frames + + @onready var hitbox_pivot: Node2D = $HitboxPivot + @onready var hitbox: Area2D = $HitboxPivot/Hitbox + @onready var hitbox_shape: CollisionShape2D = $HitboxPivot/Hitbox/CollisionShape2D + @onready var hurtbox: Hurtbox = $Hurtbox + + + func _ready() -> void: + hitbox_shape.disabled = true + hitbox.area_entered.connect(_on_hitbox_area_entered) + + + func _physics_process(_delta: float) -> void: + _read_buttons() + if hitstop_left > 0: + hitstop_left -= 1 + return + # 1. Übergänge: Ist der Zustand abgelaufen, oder startet eine Aktion? + frame += 1 + if state != State.MOVE and frame > _duration(state): + _enter(_next_state(state)) + if state == State.MOVE: + if _consume(&"attack"): + _enter(State.WINDUP) + elif _consume(&"dodge"): + _enter(State.DODGE) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + _update_hurtbox() + # 2. Verhalten im (neuen) Zustand + match state: + State.MOVE: + _move() + State.DODGE: + velocity = dodge_direction * dodge_speed + move_and_slide() + State.HITSTUN: + velocity = velocity.move_toward(Vector2.ZERO, 10.0) + move_and_slide() + if buffer_age >= 0: + buffer_age += 1 + + + func _read_buttons() -> void: + for action: StringName in [&"attack", &"dodge"]: + if Input.is_action_just_pressed(action): + HitLog.add("Druck %s" % action) + buffered_action = action + buffer_age = 0 + + + func _consume(action: StringName) -> bool: + if buffered_action == action and buffer_age >= 0 and buffer_age <= input_buffer: + buffer_age = -1 + return true + return false + + + func _move() -> void: + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + if direction != Vector2.ZERO: + facing = direction.normalized() + hitbox_pivot.rotation = facing.angle() + velocity = direction * move_speed + move_and_slide() + + + func _duration(s: State) -> int: + match s: + State.WINDUP: return attack.windup + State.ACTIVE: return attack.active + State.RECOVERY: return attack.recovery + State.DODGE: return dodge_frames + State.DODGE_RECOVERY: return dodge_recovery + State.HITSTUN: return hitstun_frames + return 0 + + + func _next_state(s: State) -> State: + match s: + State.WINDUP: return State.ACTIVE + State.ACTIVE: return State.RECOVERY + State.DODGE: return State.DODGE_RECOVERY + return State.MOVE + + + func _enter(new_state: State) -> void: + HitLog.add("Spieler %s -> %s" % [State.keys()[state], State.keys()[new_state]]) + state = new_state + frame = 1 + # Hitbox nur in der aktiven Phase. set_deferred, weil _enter auch aus Treffer-Signalen + # kommt und Godot Formen während der Physik-Abfrage nicht umschalten lässt. + hitbox_shape.set_deferred(&"disabled", new_state != State.ACTIVE) + match new_state: + State.WINDUP: + velocity = Vector2.ZERO + State.ACTIVE: + already_hit.clear() + State.DODGE: + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + dodge_direction = direction.normalized() if direction != Vector2.ZERO else facing + + + func state_name() -> String: + return State.keys()[state] + + + func is_invulnerable() -> bool: + return state == State.DODGE and frame >= invulnerable_from and frame <= invulnerable_to + + + ## i-Frames schalten die Hurtbox ab. Wird sie wieder eingeschaltet, während eine + ## gegnerische Hitbox noch aktiv ist, meldet die Physik die Überlappung neu. + func _update_hurtbox() -> void: + var off := is_invulnerable() + if off == hurtbox_off: + return + hurtbox_off = off + hurtbox.set_deferred(&"monitorable", not off) + HitLog.add("Spieler i-Frames %s (Frame %d des Ausweichens)" % ["an" if off else "aus", frame]) + + + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox or area.get_parent() in already_hit: + return + if (area as Hurtbox).receive(attack, self): + already_hit.append(area.get_parent()) + hitstop_left = attack.hitstop + + + func take_hit(enemy_attack: AttackData, source: Node2D, _hurtbox: Hurtbox) -> bool: + if is_invulnerable(): + return false + HitLog.add("Spieler getroffen in %s, Frame %d" % [State.keys()[state], frame]) + hitstop_left = enemy_attack.hitstop + hitstun_frames = enemy_attack.hitstun + velocity = (global_position - source.global_position).normalized() * enemy_attack.knockback + _enter(State.HITSTUN) + return true + ``` + + ## Häufige Fehler + + - **Lange Angriffe treffen nach den i-Frames nie.** Treffer nur in `take_hit()` abgelehnt, statt die Hurtbox abzuschalten; `area_entered` meldet nur das Hineinkommen. + - **`monitoring` statt `monitorable` umgeschaltet.** Ändert an einer Hurtbox nichts. + - **Hitstop auch beim Ausweichen.** Der Angreifer setzt `hitstop_left`, bevor er weiß, ob der Treffer zählt. Seine aktive Phase verlängert sich dann bei jedem Ausweichen. + - **Telegraph und Hitbox versehentlich unterschiedlich groß.** Der Spieler weicht der gelben Fläche aus und wird trotzdem getroffen, oder umgekehrt. Soll die Fläche bewusst nur andeuten, ist das eine Designentscheidung; dann gehört sie so ins Protokoll. + - **Ausweichrichtung ungewollt nur aus der Blickrichtung.** Liest der Code den Stick nicht, muss der Spieler erst laufen, um in eine neue Richtung auszuweichen. + - **Spieler- und Gegnerwerte im selben Lauf geändert.** Dann lässt sich keine Änderung einem Ergebnis zuordnen. + + ## Zusammenfassung + + - Ausweichen sind zwei Zustände: `DODGE` mit Bewegung und i-Frames, `DODGE_RECOVERY` danach. Richtung aus dem Stick, sonst Blickrichtung. + - i-Frames schalten die Hurtbox mit `monitorable` ab. Geht sie wieder an, meldet die Physik eine noch bestehende Überlappung neu. + - Die Figur entscheidet in `take_hit()`, ob ein Treffer zählt; Hitstop gibt es nur für gezählte Treffer. + - Der Wind-up eines gegnerischen Angriffs ist sein Telegraph. Eine Liste von `AttackData` ist das Muster. + - Das Punish-Fenster liest du aus dem Log: Frames, in denen du frei bist und der Gegner noch in der Erholung. + - Große Gegner sind derselbe Code mit größeren Flächen, mehreren Hurtbox-Zonen und `interruptible` aus. +exercises: + - id: iframes-umsetzen + type: choice + title: Ablehnen oder abschalten? + prompt: | + Ein Gegnerangriff ist 20 Frames aktiv, die i-Frames dauern 11 Frames. Welche Aussagen über die beiden Umsetzungen stimmen? + options: + - text: Lehnt nur `take_hit()` in den i-Frames ab, trifft der Angriff nicht mehr, wenn die Figur nach den i-Frames in der Hitbox stehen bleibt. + correct: true + why: "`area_entered` kam schon während der i-Frames. Eine zweite Meldung gibt es nur beim erneuten Hineinkommen." + - text: Schaltet die Figur ihre Hurtbox mit `monitorable` ab und danach wieder an, meldet die Physik die Überlappung neu. + correct: true + why: Eine wieder eingeschaltete Fläche ist für die Physik eine neue Überlappung. Gemessen mit Godot 4.7.2. + - text: Beide Umsetzungen verhalten sich gleich, solange die Hitbox kürzer aktiv ist als die i-Frames. + correct: false + why: Entscheidend ist, ob die i-Frames enden, während die Hitbox noch an ist. Ein Angriff mit 4 aktiven Frames ab Frame 30 trifft eine Figur, die in Frame 20 auf der Stelle ausweicht, mit `monitorable` in Frame 33; die Fassung, die nur ablehnt, trifft nie. Gemessen mit Godot 4.7.2. + - text: Die Hurtbox mit `monitoring = false` abzuschalten reicht. + correct: false + why: "`monitoring` steuert, ob die Hurtbox selbst etwas erkennt. Sie erkennt ohnehin nichts; gefunden wird sie über `monitorable`." + explanation: | + Der Unterschied liegt im Signal: `area_entered` meldet nur Übergänge von draußen nach drinnen. Wer einen Treffer während der i-Frames nur ablehnt, verliert ihn endgültig, sobald die Figur in der Fläche bleibt. Wird die Hurtbox abgeschaltet und wieder eingeschaltet, entsteht ein neuer Übergang. Gleich verhalten sich beide Fassungen nur, wenn die aktive Phase endet, bevor die i-Frames enden. Deshalb gehört „zu früh ausweichen und stehen bleiben“ gegen einen langen Angriff in jeden Test. + resources: + - title: Godot Docs · Area2D.monitorable + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-property-monitorable + + - id: ausweichen-zeitleiste + type: order + title: Ein Ausweichen von Anfang bis Ende + prompt: | + Bring die Ereignisse eines Ausweichens mit den Platzhaltern (18 Frames, i-Frames 2 bis 12, Erholung 8) in die richtige Reihenfolge. + lang: text + lines: | + Druck dodge, die Figur wechselt nach DODGE (Frame 1, verwundbar) + i-Frames an, die Hurtbox ist aus (Frame 2) + i-Frames aus, die Hurtbox ist wieder an (Frame 13) + Ende der Bewegung, Wechsel nach DODGE_RECOVERY + Erholung vorbei, Wechsel nach MOVE + explanation: | + Frame 1 ist mit den Platzhaltern verwundbar: Ein Treffer, der in diesem Frame ankommt, zählt. Dann folgen elf Frames i-Frames, danach bewegt sich die Figur noch sechs Frames verwundbar weiter, bevor sie acht Frames steht. Diese Reihenfolge ist eine Designentscheidung im Gerüst. Ein Ausweichen ohne verwundbaren Anfang oder ohne Erholung ist genauso baubar. + + - id: treffer-nach-iframes + type: output + title: Wann trifft der lange Angriff? + prompt: | + Die Figur weicht auf der Stelle aus (`dodge_speed = 0`), i-Frames 2 bis 12. Ein Gegnerangriff ist von Frame 302 bis 321 aktiv, die Figur steht die ganze Zeit in seiner Fläche. In welchem Frame steht `Spieler getroffen`? + lang: text + code: | + 300 Druck dodge + 300 Spieler MOVE -> DODGE + 301 Spieler i-Frames an (Frame 2 des Ausweichens) + 302 Gegner TELEGRAPH -> ACTIVE + ??? Spieler getroffen in DODGE, Frame 13 + expected: | + 313 + hints: + - Frame 12 des Ausweichens ist Frame 311. + - Die Hurtbox geht in Frame 312 wieder an. Wann meldet die Physik eine neue Überlappung? + explanation: | + Frame 1 des Ausweichens ist 300, also ist Frame 12 der Frame 311. In Frame 312 schaltet `_update_hurtbox()` die Hurtbox wieder an, der Physik-Schritt am Ende dieses Frames erkennt die Überlappung, und das Signal kommt in Frame 313. Im Log steht dabei „Frame 13“, weil die Figur in Frame 312 zuletzt gerechnet hat. Dasselbe Muster ist mit Godot 4.7.2 gemessen: Ausweichen ab Frame 28, Treffer in Frame 41. + resources: + - title: Godot Docs · Using Area2D + url: https://docs.godotengine.org/en/stable/tutorials/physics/using_area_2d.html + + - id: ausweich-strecke + type: output + title: Wie weit trägt das Ausweichen? + prompt: | + `dodge_speed` ist 240 Pixel pro Sekunde, `dodge_frames` 15. Die Figur bewegt sich in jedem Frame von `DODGE`. Wie viele Pixel legt sie zurück? Antworte mit der Zahl. + lang: text + code: | + dodge_speed = 240 # Pixel pro Sekunde + dodge_frames = 15 + expected: | + 60 + accept: + - 60 px + - 60 Pixel + explanation: | + Pro Frame bewegt sich die Figur `240 / 60 = 4` Pixel, in 15 Frames also 60. Mit den Platzhaltern der Testszene (220 Pixel pro Sekunde, 18 Frames) sind es 66; Godot 4.7.2 misst 65,99998. Die Strecke ist für die Draufsicht wichtiger als die Geschwindigkeit allein: Sie entscheidet, ob ein Ausweichen aus einer großen Angriffsfläche herausführt oder die i-Frames das allein schaffen müssen. + + - id: hurtbox-abschalten + type: gap + title: Die Hurtbox für die i-Frames + prompt: | + Ergänze `_update_hurtbox()`, sodass die Hurtbox während der i-Frames für gegnerische Hitboxen unsichtbar ist und das auch aus einem Treffer-Signal heraus funktioniert. + lang: gdscript + code: | + func _update_hurtbox() -> void: + var off := is_invulnerable() + if off == hurtbox_off: + return + hurtbox_off = off + hurtbox.⟦set_deferred⟧(&"⟦monitorable⟧", ⟦not off⟧) + gaps: + - label: Verzögert setzen + hint: Dieselbe Methode wie für `disabled` der Hitbox. + - label: Welche Eigenschaft? + hint: Nicht „erkennt selbst“, sondern „kann gefunden werden“. + - label: Neuer Wert + hint: Während der i-Frames soll die Hurtbox nicht gefunden werden. + accept: ["!off", "off == false"] + explanation: | + `monitorable` entscheidet, ob andere Flächen die Hurtbox finden. Während der i-Frames ist `off` wahr, also soll `monitorable` falsch sein: `not off`. `set_deferred()` ist Vorsorge: `_update_hurtbox()` läuft heute nur in `_physics_process`, wo eine direkte Zuweisung ginge, aber wer es später aus einem Treffer-Signal aufruft, bekommt sonst die Fehlermeldung aus dem Godot-Start. `hurtbox_off` merkt sich den letzten Stand, damit die Zeile nur beim Wechsel läuft und das Log nicht jeden Frame eine Zeile schreibt. + resources: + - title: Godot Docs · Area2D.monitorable + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-property-monitorable + + - id: ausweichrichtung + type: gap + title: Ausweichen in alle Richtungen + prompt: | + Ergänze die Zeile: Zeigt der Stick eine Richtung, weicht die Figur dorthin aus, sonst in Blickrichtung. + lang: gdscript + code: | + var direction := Input.get_vector(&"move_left", &"move_right", &"move_up", &"move_down") + dodge_direction = ⟦direction.normalized()⟧ if ⟦direction != Vector2.ZERO⟧ else ⟦facing⟧ + gaps: + - label: Richtung des Sticks, Länge 1 + hint: Ein halb gedrückter Stick soll nicht halb so weit ausweichen. + - label: Bedingung + hint: Wann zeigt der Stick überhaupt eine Richtung? + accept: ["not direction.is_zero_approx()", "direction.length() > 0", "direction.length() > 0.0", "Vector2.ZERO != direction", "direction != Vector2()", "direction.length_squared() > 0"] + - label: Sonst + hint: Die gemerkte Blickrichtung der Figur. + explanation: | + `normalized()` macht aus jedem Stick-Ausschlag einen Vektor der Länge 1. Ohne das wiche die Figur bei leicht gedrücktem Stick nur ein Stück weit aus, weil `get_vector()` kleine Ausschläge kürzer liefert. Ob ein halber Ausschlag ein kürzeres Ausweichen geben soll, ist eine Designfrage; das Gerüst entscheidet sich für immer gleich weit. Ohne Richtung nimmt die Figur `facing`, die Richtung, in die sie zuletzt gelaufen ist. + resources: + - title: Godot Docs · Vector2.normalized + url: https://docs.godotengine.org/en/stable/classes/class_vector2.html#class-vector2-method-normalized + + - id: hitstop-beim-ausweichen + type: bug + title: Der Gegner friert beim Ausweichen ein + prompt: | + In einer Fassung, in der die Figur Treffer während der i-Frames nur in `take_hit()` ablehnt: Weicht die Figur aus, steht der Gegner trotzdem 4 Frames still, und seine aktive Phase endet im Log 4 Frames später als eingestellt. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _on_hitbox_area_entered(area: Area2D) -> void: + if not area is Hurtbox: + return + var accepted := (area as Hurtbox).receive(current_attack(), self) + hitstop_left = current_attack().hitstop + lines: [5] + fix: + - line: 5 + answers: + - "if accepted: hitstop_left = current_attack().hitstop" + - "hitstop_left = current_attack().hitstop if accepted else 0" + - "hitstop_left = current_attack().hitstop if accepted else hitstop_left" + explanation: | + `receive()` gibt zurück, ob der Treffer gezählt hat. Zeile 5 setzt den Hitstop aber in jedem Fall, auch wenn die Figur gerade unverwundbar war. Dann verlängert jedes Ausweichen die aktive Phase des Gegners um den Hitstop, und das Punish-Fenster verschiebt sich, ohne dass jemand es so wollte. Genau dieser Fehler steckte in einer frühen Fassung des Gerüsts und fiel erst im Log auf: `ACTIVE -> RECOVERY` stand 4 Frames zu spät da. + + - id: monitoring-verwechselt + type: bug + title: Der lange Angriff trifft wieder nie + prompt: | + Nach einem Umbau trifft der 20 Frames lange Angriff nicht mehr, wenn die Figur am Anfang ausweicht und stehen bleibt. Das Log zeigt `i-Frames an` und `i-Frames aus`, aber keinen Treffer. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _update_hurtbox() -> void: + var off := is_invulnerable() + if off == hurtbox_off: + return + hurtbox_off = off + hurtbox.set_deferred(&"monitoring", not off) + lines: [6] + fix: + - line: 6 + answers: + - hurtbox.set_deferred(&"monitorable", not off) + - hurtbox.set_deferred("monitorable", not off) + - hurtbox.set_deferred(&"monitorable", !off) + - hurtbox.set_deferred("monitorable", !off) + - hurtbox.set_deferred(&"monitorable", off == false) + - hurtbox.set_deferred("monitorable", off == false) + - hurtbox.monitorable = not off + - hurtbox.monitorable = !off + explanation: | + `monitoring` legt fest, ob eine Fläche selbst Überlappungen meldet. Die Hurtbox hat `monitoring` ohnehin aus, also ändert die Zeile nichts. Gefunden wird sie über `monitorable`, und nur dessen Wechsel erzeugt nach den i-Frames eine neue Überlappung. Mit Godot 4.7.2 nachgestellt: Mit `monitoring` trifft der lange Angriff nie, mit `monitorable` einen Frame nach dem Ende der i-Frames. + resources: + - title: Godot Docs · Area2D.monitoring + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-property-monitoring + + - id: punish-fenster-lesen + type: explain + title: Zehn Frames für die Antwort + prompt: | + Erkläre an diesem Log, wie lange das Punish-Fenster ist und wovon es abhängt. Warum trifft die Figur in diesem Lauf trotzdem nicht? + lang: text + code: | + 34 Dummy ACTIVE -> RECOVERY + 46 Spieler DODGE -> DODGE_RECOVERY + 54 Spieler DODGE_RECOVERY -> MOVE + 64 Dummy RECOVERY -> WAIT + points: + - Die Figur ist ab Frame 54 frei, der Dummy erholt sich bis Frame 63; das Fenster ist 10 Frames lang. + - Mit 8 Frames Wind-up trifft ein Angriff mit Start in Frame 54 oder 55 noch in der Erholung; die Meldung aus Frame 64 kommt, bevor der Dummy sie verlässt. + - Das Fenster hängt an der Erholung des Gegners, an Dauer und Erholung des Ausweichens und am Wind-up des eigenen Angriffs. + - Die Figur trifft nicht, weil das Ausweichen sie 66 Pixel weit getragen hat und sie außer Reichweite steht. + explanation: | + Ein Punish-Fenster ist die Zeit, in der du frei bist und der Gegner noch nicht. Hier beginnt es mit Frame 54 und endet mit Frame 63, zehn Frames. Ein Angriff mit 8 Frames Wind-up kann in Frame 54 oder 55 starten und trifft dann in Frame 63 oder 64; die Meldung aus Frame 64 kommt am Anfang des Frames, bevor der Dummy seine Erholung verlässt. Ob das reicht, hängt aber auch am Weg: Ein Ausweichen, das weit trägt, bringt dich aus der Gefahr und zugleich aus der Reichweite. Diese Spannung ist eine der Stellen, an denen du für dein Spiel entscheiden musst, was es belohnen soll. + resources: + - title: Hades 2 Early Access Patch 1 (Shacknews) + url: https://www.shacknews.com/article/139921/hades-2-early-access-patch-notes-1 + + - id: interruptible-erklaeren + type: explain + title: Warum der große Gegner weitermacht + prompt: | + Erkläre, was `interruptible = false` am großen Trainingsgegner im Log bewirkt und was es nicht abschaltet. + lang: gdscript + code: | + func take_hit(attack: AttackData, source: Node2D, hurtbox: Hurtbox) -> bool: + hits_taken += 1 + HitLog.add("%s getroffen: Zone %s, Schaden %.1f, in %s Frame %d" % [name, hurtbox.zone, attack.damage * hurtbox.damage_multiplier, State.keys()[state], frame]) + hitstop_left = attack.hitstop + var flash := create_tween() + body.modulate = Color(3, 3, 3) + flash.tween_property(body, ^"modulate", Color.WHITE, 0.15) + if interruptible: + hitstun_frames = attack.hitstun + velocity = (global_position - source.global_position).normalized() * attack.knockback + _enter(State.HITSTUN) + return true + points: + - Mit `interruptible = false` wechselt der Gegner nach einem Treffer nicht in `HITSTUN` und setzt sein Muster fort. + - Treffer zählen trotzdem; das Log schreibt Zone und Schaden, und `hits_taken` steigt. + - Den Hitstop bekommt er weiterhin, deshalb verschiebt sich sein Muster um die Hitstop-Frames. + - Ob und wann ein großer Gegner unterbrechbar ist, ist eine Designentscheidung; die Eigenschaft macht sie nur einstellbar. + explanation: | + `take_hit()` schreibt den Treffer ins Log, setzt den Hitstop und lässt den Gegner blinken. Erst die Zeile `if interruptible:` entscheidet, ob er zurückgestoßen wird und in `HITSTUN` wechselt. Ohne Unterbrechung bleibt er in seinem Muster, das sich nur um die Hitstop-Frames verschiebt: Im Log beginnt der Telegraph in Frame 34 statt 30. Ob deine großen Gegner unterbrechbar sind, immer oder nur in bestimmten Phasen, legst du fest. + + - id: ausweichen-einbauen + type: practice + title: Ausweichen und Trainingsgegner einbauen + prompt: | + Ersetze `player.gd`, leg die Aktion `dodge` an und gib dem Dummy einen Angriff (Platzhalter: Wind-up 20, aktiv 4, Erholung 30, `wait_frames` 10). Stelle mit dem Drehbuch aus [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) die drei Fälle aus der Lektion frame-genau nach: zu spät ausgewichen (`29: dodge`), gerade noch (`28: dodge`) und, mit `active = 20` und `dodge_speed = 0`, zu früh gegen den langen Angriff (`27: dodge`). + lang: text + solution: | + Die Hitbox des Dummys geht in Frame A an. + Zu spät, Druck in A: Treffer in A + 1, „Frame 1“ + Gerade noch, Druck in A-1: i-Frames an in A, kein Treffer + Zu früh mit dodge_speed 0 gegen den langen Angriff, + Druck in D: i-Frames aus in D + 12, + Treffer in D + 13 + checklist: + - Der Dummy zeigt den Telegraph als gelbe Fläche, so groß wie seine Hitbox. + - Alle drei Fälle stehen so im Log wie in der Lektion, mit denselben Abständen. + - Weicht die Figur aus, endet die aktive Phase des Dummys nach genau so vielen Frames wie eingestellt (kein Hitstop ohne Treffer). + - Du hast `dodge_speed` danach wieder auf deinen Testwert gestellt. + explanation: | + Die drei Fälle decken die Stellen ab, an denen Ausweich-Code am häufigsten falsch ist: der erste, verwundbare Frame, die i-Frames selbst und das Ende der i-Frames in einer noch aktiven Fläche. Stimmen sie mit der Lektion überein, kannst du dich bei allen weiteren Tests auf das Log verlassen. Weicht ein Fall ab, vergleiche zuerst `_update_hurtbox()` und die Ebenen der Hurtbox. + + - id: grosser-gegner-bauen + type: practice + title: Den großen Trainingsgegner aufstellen + prompt: | + Bau den großen Trainingsgegner mit den Zonen `kopf` und `bein` und `interruptible` aus. Prüfe im Log, welche Zone du von welcher Seite triffst. Notiere danach drei Fragen, die du für einen eigenen großen Gegner beantworten müsstest. Antworten brauchst du nicht. + lang: text + solution: | + Beispiel für die Form, nicht für deinen Gegner: + + Log: Von links unten trifft der Schlag nur „bein“. + Von oben trifft er „kopf“, Schaden 2.0. + Fragen: + 1. Soll eine Zone nur in bestimmten Phasen erreichbar sein? + 2. Wie zeigt der Gegner, dass er gerade nicht unterbrechbar ist? + 3. Wohin soll der Spieler während des Stampfers ausweichen? + checklist: + - Jede Hurtbox ist ein direktes Kind des großen Gegners und hat eine eigene `zone`. + - Das Log zeigt für beide Zonen mindestens einen Treffer mit dem passenden Schaden. + - Mit `interruptible` aus wechselt der Gegner nach einem Treffer nicht in `HITSTUN`. + - Deine drei Fragen betreffen Entscheidungen, nicht Werte; sie sind von dir und bleiben vorerst offen. + explanation: | + Ein großer Gegner ist in der Testszene derselbe Code mit anderen Flächen. Was ihn als Gegner ausmacht, steckt in den Entscheidungen hinter diesen Flächen: welche Zone wann erreichbar ist, wie er seine Unverwundbarkeit zeigt, wohin der Spieler soll. Die Fragen hältst du fest, damit du sie später gezielt testest. Antworten gibt dir der Kurs nicht; sie gehören zu deinem Spiel. + resources: + - title: Godot Docs · GDScript exported properties + url: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html diff --git a/content/combat/modules/trefferfeedback.yaml b/content/combat/modules/trefferfeedback.yaml new file mode 100644 index 0000000..f1479b3 --- /dev/null +++ b/content/combat/modules/trefferfeedback.yaml @@ -0,0 +1,536 @@ +id: trefferfeedback +title: "Trefferfeedback: Hitstop, Screenshake, Blitz und Ton" +level: 2 +minutes: 100 +summary: Was im Moment eines Treffers passiert, damit der Spieler ihn bemerkt – Hitstop, Screenshake, Blitz, Trefferzeichen, Knockback und Ton –, wie veröffentlichte Spiele diese Mittel dosieren und was jedes davon kostet. +goals: + - Du beschreibst Hitstop mit Sakurais Techniken und rechnest aus, um wie viele Frames er eine Aktion verlängert. + - Du begründest mit dem gemessenen Verhalten von Godot 4.7.2, warum die Testszene Figuren einzeln anhält statt über `Engine.time_scale`. + - Du modellierst Screenshake als abklingenden Trauma-Wert und rechnest ihn für 320 × 180 in ganze Pixel um. + - Du nennst Regeln, nach denen Blitz und Trefferzeichen sichtbar sind, ohne Figuren zu verdecken. + - Du ordnest Knockback und Trefferton veröffentlichter Spiele ein und nennst ihre Kosten. +resources: + - title: Masahiro Sakurai · Eight Hit Stop Techniques + url: https://www.youtube.com/watch?v=tycbMSjDDLg + - title: Squirrel Eiserloh · Juicing Your Cameras With Math (GDC 2016) + url: https://www.youtube.com/watch?v=tu-Qe66AvtY + - title: Jan Willem Nijman (Vlambeer) · The art of screenshake + url: https://www.youtube.com/watch?v=AJdEqssNZ-U + - title: Ian Cofino · Dreamscaper – Killer Combat on an Indie Budget (GDC 2022) + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + - title: Godot Docs · Camera2D.offset + url: https://docs.godotengine.org/en/stable/classes/class_camera2d.html#class-camera2d-property-offset +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Im Log der Testszene trifft der Angriff den Dummy in Frame 21. Was du in diesem Moment siehst, steht nicht im Log: Beide Quadrate halten 4 Frames an, der Dummy leuchtet auf und wird mit 120 Pixeln pro Sekunde zurückgestoßen. Ohne diese Signale hätte die Kollision genauso gezählt. Ob der Spieler den Treffer bemerkt, hängt an ihnen. + + Dieses Modul geht die Mittel einzeln durch: Hitstop, Screenshake, Blitz und Trefferzeichen, Knockback, Ton. Zu jedem steht, was es tut, wie veröffentlichte Spiele es dosieren und was es kostet. Wie viel Rückmeldung dein Spiel bekommt, entscheidest du. Die Zahlen der Testszene sind Platzhalter. + + ## Hitstop: Angreifer und Ziel halten an + + **Hitstop** heißt: Beim Treffer frieren Angreifer und Ziel für einige Frames ein. Masahiro Sakurai, Director von Super Smash Bros., erklärt das in [Stop for Big Moments!](https://www.youtube.com/watch?v=OdVkEOzdCPw). Ab etwa 1:12 zeigt er den „Boss Stop“ mit Blitz und Einfrieren, den es seit Kirby's Adventure gibt. In [Eight Hit Stop Techniques](https://www.youtube.com/watch?v=tycbMSjDDLg) zählt er auf, wie Smash den Stillstand gestaltet: + + - Das Opfer zittert stärker als der Angreifer (ab 0:58). + - Die Hurtboxen bewegen sich nicht mit (1:21). Das Zittern ist nur Bild. + - Am Boden zittert die Figur seitlich, in der Luft in alle Richtungen (1:38). + - Das Zittern klingt ab (2:09). + - Die Länge wächst mit dem Schaden, mal einem Faktor (2:26). + - Etwa 4 Frames dauert der Übergang in die Schadenspose (3:13). + - Der Angreifer bewegt sich mit einem Bruchteil seiner Geschwindigkeit weiter (3:43). + - Im Mehrspieler ist der Hitstop kürzer (4:30). + + Andere Spiele dosieren anders: + + | Quelle | Länge | Was hält an | + | --- | --- | --- | + | Vlambeer | etwa 20 ms | das ganze Spiel | + | Dreamscaper | etwa 115 ms | nur die Figuren | + | Smash | wächst mit Schaden | Angreifer, Opfer | + | Testszene | 4 Frames | Angreifer, Ziel | + + Jan Willem Nijman von Vlambeer nennt den Stillstand in [The art of screenshake](https://www.youtube.com/watch?v=AJdEqssNZ-U) ab etwa 18:01 „sleep“: Das Spiel pausiert beim Treffer „for 20 milliseconds or something“, gut ein Frame. Ian Cofino beschreibt für das Action-Roguelike Dreamscaper in der Draufsicht ab etwa 36:42 einen Hitstop von „about 115 milliseconds like seven frames“. Er gilt nur für Angriffe des Spielers, und es frieren nur die Figuren ein ([GDC 2022](https://www.youtube.com/watch?v=3Omb5exWpd4)). + + ### Was der Hitstop kostet + + Jeder angehaltene Frame fehlt der Aktion. Das echte Log der Testszene mit Hitstop 4: + + ```text + 12 Druck attack + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body … + 21 Dummy WAIT -> HITSTUN + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + ``` + + Ohne Treffer wechselt der Spieler in Frame 23 in die Erholung und ist ab Frame 37 frei. Mit Treffer erst in Frame 27 und ab Frame 41. Der Dummy ist ab Frame 36 frei, der Angreifer ist also 5 Frames im Nachteil. Ein längerer Hitstop ändert diesen Abstand nicht, denn beide setzen denselben Wert. Er verlängert nur die ganze Szene. + + Teuer wird es, wenn ein Dritter mitspielt. Sakurai begrenzt den Hitstop in Smash, weil ein dritter Spieler das Einfrieren ausnutzen könnte ([Kolumne, Fan-Übersetzung](https://sourcegaming.info/2015/11/11/thoughts-on-hitstop-sakurais-famitsu-column-vol-490-1/)). In der Testszene gilt dasselbe im Kleinen: Der Hitstop hält nur Angreifer und Ziel an. Alle anderen Gegner laufen weiter, während deine Figur steht. + + ### Lokal oder global: Wer steht still? + + Vlambeer hält das ganze Spiel an, Dreamscaper und die Testszene nur die Beteiligten. In Godot liegt für den globalen Weg `Engine.time_scale = 0` nahe. Das hält aber nur an, was mit `delta` rechnet. `_physics_process` läuft weiter, mit Godot 4.7.2 gemessen sechs Aufrufe in 0,1 Sekunden ([Engine.time_scale](https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-property-time-scale)). Ein Zustandsautomat, der Frames zählt, zählt also weiter (siehe [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff)). Die Testszene gibt deshalb jeder Figur einen Zähler `hitstop_left`. Solange er läuft, kehrt ihr `_physics_process` sofort zurück. + + Der lokale Weg hat eine Folge, die Sakurai ausdrücklich will: Effekte laufen weiter. Seine Trefferzeichen bewegen sich im Hitstop weiter ([Hit Marks](https://www.youtube.com/watch?v=B-P4ysHSjCg), ab etwa 4:01). Der Blitz des Dummys ist ein Tween über 0,15 Sekunden. Tweens laufen standardmäßig im Takt der gezeichneten Bilder, und der Zähler der Figur hält sie nicht an. Ein Tween reagiert dagegen auf `Engine.time_scale`, außer du rufst `set_ignore_time_scale()` auf ([Tween](https://docs.godotengine.org/en/stable/classes/class_tween.html#class-tween-method-set-ignore-time-scale)). + + ### Die Pose im Stillstand + + Im Hitstop sieht der Spieler ein Standbild. Laut Sakurai bleibt die eingefrorene Angriffspose im Gedächtnis ([Attack Poses](https://www.youtube.com/watch?v=99gdMDF7V2E), ab 0:29). Frame 1 der Schadensanimation steht während des ganzen Hitstops im Bild, deshalb zeigt er eine extreme Schmerzpose ([Damage Animations](https://www.youtube.com/watch?v=0xHE3ypX96U), ab 0:12). Für Pixelart heißt das: Zwei Bilder tragen den Treffer, die Angriffspose des Angreifers und das erste Schadensbild des Ziels. Wie du solche Posen zeichnest, behandelt der geplante Kurs zu 2D-Animation. + + ## Screenshake: abklingend und in ganzen Pixeln + + **Screenshake** ist Kamerawackeln. Sakurai unterscheidet in [Screen Shake](https://www.youtube.com/watch?v=2JXR7IASSog) zwei Wege: die Kamera bewegen oder das fertige Bild schütteln (ab 0:26). Kleine, mittlere und große Erschütterungen haben bei ihm eigene Muster, kleine wackeln nur senkrecht (1:27). Das Wackeln beginnt stark und klingt ab, mit etwas Zufall (2:43). + + Squirrel Eiserloh macht daraus ein Modell ([Juicing Your Cameras With Math](https://www.youtube.com/watch?v=tu-Qe66AvtY), GDC 2016). Ein Wert **trauma** zwischen 0 und 1 steigt mit jedem Treffer, bei einem harten Treffer etwa um 0,5, bei kleinem Stress um 10 bis 20 % (ab 3:46). trauma sinkt linear. Die Stärke des Wackelns ist trauma² oder trauma³ (4:16). In 2D verschiebt er die Kamera, in 3D dreht er sie (8:06). Für die Richtung nimmt er Perlin-Rauschen statt Zufallszahlen: Es funktioniert auch in Zeitlupe und hängt nicht von der Bildrate ab (11:47 bis 14:04). + + Das Modell rechnet die Stärke in Pixel um. `MAX_PX` ist der Ausschlag bei trauma 1, hier ein Platzhalter: + + ```python run + MAX_PX = 4 # Platzhalter: Ausschlag bei 1,0 + + print("trauma linear quadrat") + for zehntel in range(10, 0, -1): + trauma = zehntel / 10 + linear = round(MAX_PX * trauma) + quadrat = round(MAX_PX * trauma ** 2) + print(f"{trauma:.1f} {linear} {quadrat}") + ``` + + Linear wackelt die Kamera bis trauma 0,2 noch um einen Pixel, mit trauma² steht sie schon ab 0,3 still. Das Quadrat lässt kleine Treffer fast ruhig und das Ende schneller verschwinden. Bei 320 × 180 kommt eine eigene Rechnung dazu, keine Aussage über veröffentlichte Spiele: Das Modell rundet auf ganze Spielpixel, damit nichts zwischen zwei Pixeln steht. Ein Spielpixel ist bei vierfacher Skalierung 4 Bildschirmpixel breit, der kleinste Ausschlag springt also gleich um 4 Bildschirmpixel. Itay Keren beschreibt für Hyper Light Drifter einen anderen Weg: Das Spiel zeichnet seine Pixelgrafik auf eine Fläche und bewegt diese in echten Bildschirmpixeln, für weiches Scrollen ([GDC 2015](https://www.youtube.com/watch?v=pdvCO97jOQk), ab etwa 17:39). Ob du so etwas für einen Shake willst, entscheidest du. + + In Godot verschiebst du dafür `Camera2D.offset`. Die Doku nennt die Eigenschaft „useful for looking around or camera shake animations“ ([Camera2D.offset](https://docs.godotengine.org/en/stable/classes/class_camera2d.html#class-camera2d-property-offset)). Ein Gerüst, das trauma in jedem Frame abbaut; `_rauschen()` liefert einen Vektor mit x und y zwischen −1 und 1: + + ```gdscript + func add_trauma(menge: float) -> void: + trauma = minf(trauma + menge, 1.0) + + + func _physics_process(_delta: float) -> void: + var s := trauma * trauma + var dir := _rauschen() + camera.offset = (dir * max_px * s).round() + trauma = maxf(trauma - abbau, 0.0) + ``` + + Screenshake kostet Lesbarkeit und Verträglichkeit. Cofino stuft ihn in Dreamscaper nach Angriffsklasse ab. Wird der Spieler selbst getroffen, wackelt die Kamera „much less than any applied to an enemy“ (ab 38:29). Zu jedem Shake gibt es eine Vibration (39:10). + + > [!WARNING] Übelkeit + > Nijman erzählt ab etwa 32:19, dass Nuclear Throne eine Option brauchte, um den Screenshake abzuschalten, weil manchen Spielern übel wurde. Bündle die Stärke deshalb an einer Stelle, etwa in `max_px`. Dann ist ein Regler im Menü später eine Zeile. + + ## Blitz und Trefferzeichen: sichtbar, ohne zu verdecken + + Ein kurzer Blitz zeigt, wer getroffen wurde. Nijman lässt Gegner beim Treffer weiß aufblitzen (ab 12:06). Sakurai warnt in [Flashing Effects](https://www.youtube.com/watch?v=Mmw39T_dBLI): Reines Weiß zerstört den Kontrast und verschwindet auf hellem Grund, deshalb mischt er Dunkles bei (ab 1:01). Farben tragen bei ihm Bedeutung: Weißes Blinken heißt unverwundbar, gelbes nicht greifbar (2:51 bis 3:19). Nimmst du dieselbe Farbe für zwei Zustände, kann der Spieler sie nicht unterscheiden. + + Der Dummy der Testszene setzt `modulate` beim Treffer auf `Color(3, 3, 3)` und blendet in 0,15 Sekunden zurück, das sind 9 Frames bei 60 Bildern pro Sekunde ([CanvasItem.modulate](https://docs.godotengine.org/en/stable/classes/class_canvasitem.html#class-canvasitem-property-modulate)). + + **Trefferzeichen** sind Effekte an der Trefferstelle, etwa ein Stern oder ein Funke. Sakurai nennt in [Hit Marks](https://www.youtube.com/watch?v=B-P4ysHSjCg) sieben Regeln (1:15 bis 3:46): + + 1. auffällig genug + 2. starker Kontrast + 3. an der Trefferstelle + 4. aus mehreren Schichten + 5. verdeckt die Figuren nicht + 6. passende Dauer + 7. dunkle Farben für helle Hintergründe + + Regel 5 wird bei Pixelart schnell eng. Die Figur der Testszene ist 12 × 12 Pixel groß. Ein Trefferzeichen, das größer ist und mittig auf ihr sitzt, verdeckt sie ganz, genau in den Frames, in denen der Spieler ihre Schmerzpose sehen soll. + + ## Knockback: Abstand als Rückmeldung und als Preis + + **Knockback** stößt das Ziel beim Treffer zurück. Nijman schiebt getroffene Gegner „back a couple pixels“ (ab 12:36). Den Spieler stößt er beim eigenen Schuss zurück, um ihm „a reason to not press it“ zu geben (17:08). Knockback ist dort auch ein Preis für die eigene Aktion. + + In Smash ist Knockback ein ganzes System. Er wächst mit der Stärke des Angriffs und dem gesammelten Schaden. Jeder Angriff hat einen Winkel, einen Wachstumsfaktor und einen Basiswert oder einen festen Wert, und Gewicht und Fallgeschwindigkeit des Opfers zählen mit ([Knockback in Super Smash Bros.](https://www.youtube.com/watch?v=HVktK4a9Yfo), 0:20 bis 2:49). + + Knockback verschiebt Abstände, und Abstände entscheiden über den nächsten Treffer. Für God of War (2018) ließ das Team Trefferreaktionen Gegner weniger weit bewegen und lenkte getroffene Gegner mit einem „strike assist“ ins Bild ([Evolving Combat in God of War](https://www.youtube.com/watch?v=hE5tWF-Ou2k), ab 33:36 und 34:53). Der Dummy der Testszene startet mit 120 Pixeln pro Sekunde und bremst in jedem Frame um 10. Wie weit er rutscht, rechnest du in einer Übung aus. + + > [!PRAXIS] Feedback als Daten + > Sakurai rechnet die Länge des Hitstops aus dem Schaden, Cofino stuft den Screenshake nach Angriffsklasse ab. Beides sind Tabellen, keine Einzelfälle. Leg Feedback-Werte deshalb wie die Frame-Daten in `AttackData` ab: Hitstop, Trauma, Knockback je Angriff. Dann änderst du einen Wert an einer Stelle und kannst zwei Fassungen vergleichen, wie in [Tuning als Experiment](/combat/tuning). + + ## Ton: viel Wirkung für wenig Aufwand + + Martin Jonasson und Petri Purho nennen Sound in [Juice it or lose it](https://www.youtube.com/watch?v=Fy0aCDmgnxg) „the most cost-effective thing“ (ab 7:46). Sakurai beschreibt in [Strong Attack, Light Reverb](https://www.youtube.com/watch?v=-nWjT49-cds) drei Regeln. Ein starker Angriff bekommt einen kurzen Hall mit der Spitze am Anfang (0:56). Wiederholte Jab-Treffer bleiben unterscheidbar (2:02). Der Hauptton eines starken Treffers endet im Hitstop (2:13). In [Strike a Balance with Sound Effects](https://www.youtube.com/watch?v=qKjD5qbLUeI) ordnet er die Lautstärke nach Wichtigkeit: KO zuerst, dann Treffer und Spezialangriffe, Schritte zuletzt (1:01 bis 3:14). + + Für Hyper Light Drifter bauten Akash Thakkar und Rich Vreeland einen Schuss der Diamond Shotgun aus 9 Schichten, manche Sounds aus bis zu etwa 20 ([GDC 2017](https://www.youtube.com/watch?v=k1b9xKRC74E), ab etwa 30:16). Die Schichten verteilen sie auf hohe, mittlere und tiefe Frequenzen, die Wucht kommt aus dem Bass (Fragerunde, 55:46 und 57:01). Nicolae Berbece rät, die Tonhöhe bei jedem Abspielen leicht zu ändern ([GDC Europe 2015](https://www.youtube.com/watch?v=pmSAG51BybY), ab etwa 13:04). + + Wie man solche Töne baut, behandelt der geplante Kurs zu Sounddesign. Für das Timing gilt: Der Ton gehört in den Frame, in dem der Treffer gemeldet wird, nicht in den, in dem die Hitbox angeht. Sonst klingt auch ein Schlag ins Leere wie ein Treffer. + + > [!MERKE] + > Jedes Mittel hat einen Preis. Hitstop kostet Zeit, Screenshake Lesbarkeit und Verträglichkeit, Blitz und Trefferzeichen den Blick auf die Figuren, Knockback Abstand. + + > [!INTERVIEW] + > „Unsere Treffer wirken schwach. Was änderst du zuerst?“ Gute Antwort: Erst klären, was fehlt. Bemerkt der Spieler den Treffer nicht, oder bemerkt er ihn und er wirkt trotzdem nicht? Dann ein Mittel nach dem anderen ändern und gegen die alte Fassung vergleichen, nie alle auf einmal. Zu jedem Mittel nennst du den Preis: Hitstop verlängert jede Aktion, Screenshake kann Übelkeit auslösen und braucht eine Option zum Abschalten, Effekte können die Figuren verdecken. Wie viel davon das Spiel bekommt, entscheidet der Vergleich. + + ## Häufige Fehler + + - **Hitstop mit `Engine.time_scale = 0`.** Frame-Zähler laufen weiter, und Tweens bleiben stehen, auch die Effekte, die im Hitstop weiterlaufen sollen. + - **Hitstop auch beim Ausweichen.** Der Angreifer setzt `hitstop_left`, bevor er weiß, ob der Treffer zählt. Seine aktive Phase verlängert sich dann bei jedem Ausweichen (siehe [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen)). + - **Hurtboxen zittern mit.** Dann entscheidet das Zittern über Treffer. Nur das Bild soll zittern. + - **Shake ohne Abklingen oder ohne Rückkehr.** Wird `offset` aufaddiert statt gesetzt, bleibt die Kamera nach dem Wackeln versetzt stehen. + - **Ton beim Einschalten der Hitbox.** Dann klingt jeder Fehlschlag wie ein Treffer. + - **Trefferzeichen größer als die Figur.** Bei 12 × 12 Pixeln verdeckt es die Schmerzpose. + - **Reines Weiß auf hellem Grund.** Der Blitz verschwindet. + - **Alles gleichzeitig verstärkt.** Danach weißt du nicht, welches Mittel den Unterschied gemacht hat. + + ## Zusammenfassung + + - Hitstop friert Angreifer und Ziel ein. Sakurai lässt das Opfer stärker zittern, Hurtboxen stehen, die Länge wächst mit dem Schaden. + - Veröffentlichte Werte: Vlambeer etwa 20 ms für das ganze Spiel, Dreamscaper etwa 115 ms (7 Frames) nur für die Figuren. + - Hitstop verlängert jede getroffene Aktion um seine Länge; in der Testszene endet die aktive Phase in Frame 27 statt 23. + - `Engine.time_scale = 0` hält keine Frame-Zähler an. Die Testszene hält jede Figur mit `hitstop_left` an, Effekte laufen weiter. + - Screenshake: trauma steigt mit Treffern, sinkt linear, die Stärke ist trauma². In 320 × 180 gerundet verschwindet kleines Wackeln früh. + - Blitz und Trefferzeichen brauchen Kontrast und dürfen die Figuren nicht verdecken. + - Knockback verschiebt Abstände und damit den nächsten Treffer. + - Ton ist das günstigste Mittel. Er gehört in den Frame, in dem der Treffer gemeldet wird. +exercises: + - id: sakurai-hitstop + type: choice + title: Acht Techniken für einen Stillstand + prompt: | + Welche Aussagen gehören zu Sakurais „Eight Hit Stop Techniques“? + options: + - text: Das Opfer zittert stärker als der Angreifer. + correct: true + why: Das ist die erste Technik im Clip (ab etwa 0:58). + - text: Die Hurtboxen zittern mit, damit Bild und Fläche übereinstimmen. + correct: false + why: Umgekehrt. Die Hurtboxen bleiben stehen, nur das Bild zittert (1:21). + - text: Die Länge des Hitstops wächst mit dem Schaden. + correct: true + why: Sakurai rechnet sie aus dem Schaden mal einem Faktor (2:26). + - text: Im Mehrspieler fällt der Hitstop länger aus, damit alle den Treffer sehen. + correct: false + why: Im Mehrspieler ist er kürzer (4:30). In seiner Kolumne begründet Sakurai die Grenze damit, dass ein dritter Spieler den Stillstand ausnutzen könnte. + - text: Das Zittern bleibt bis zum Ende des Hitstops gleich stark. + correct: false + why: Es klingt ab (2:09), wie später auch ein Screenshake. + explanation: | + Sakurai behandelt Hitstop als gestalteten Moment: wer stärker zittert, in welche Richtung, wie lange, und was dabei feststeht. Zwei Punkte sind technisch. Die Hurtboxen bleiben an Ort und Stelle, damit das Zittern keine Treffer auslöst oder verhindert. Und die Länge folgt aus dem Schaden, ist also ein Wert in einer Tabelle, kein Einzelfall. Die Begrenzung im Mehrspieler ist ein Preis des Hitstops. + resources: + - title: Masahiro Sakurai · Eight Hit Stop Techniques + url: https://www.youtube.com/watch?v=tycbMSjDDLg + - title: Source Gaming · Sakurais Kolumne zum Hitstop (Fan-Übersetzung) + url: https://sourcegaming.info/2015/11/11/thoughts-on-hitstop-sakurais-famitsu-column-vol-490-1/ + + - id: blitz-und-zeichen + type: choice + title: Was widerspricht Sakurais Regeln? + prompt: | + Du planst für einen Treffer einen Blitz und ein Trefferzeichen. Welche Pläne widersprechen den Regeln aus Sakurais Clips „Hit Marks“ und „Flashing Effects“? + options: + - text: Ein reinweißer Blitz auf einem hellen Sandboden. + correct: true + why: Reines Weiß verschwindet auf hellem Grund. Sakurai mischt deshalb Dunkles bei. + - text: Ein Trefferzeichen, das mittig auf der getroffenen Figur sitzt und sie ganz verdeckt. + correct: true + why: Trefferzeichen dürfen die Figuren nicht verdecken. Gerade im Hitstop soll man die Pose sehen. + - text: Das Trefferzeichen sitzt an der Trefferstelle und bewegt sich im Hitstop weiter. + correct: false + why: Beides entspricht Sakurai. Trefferzeichen gehören an die Trefferstelle und laufen im Hitstop weiter. + - text: Das Trefferzeichen besteht aus mehreren Schichten mit starkem Kontrast. + correct: false + why: Schichten und starker Kontrast stehen beide in seiner Liste. + explanation: | + Blitz und Trefferzeichen sollen zeigen, wo und wen es getroffen hat, und dabei nicht das verdecken, was der Spieler lesen muss. Kontrast ist das Werkzeug, Weiß allein reicht dafür nicht immer. Bei Pixelart mit kleinen Figuren wird die Regel „nicht verdecken“ schnell zur Größenfrage. Wie groß dein Trefferzeichen ist, entscheidest du. + resources: + - title: Masahiro Sakurai · Hit Marks + url: https://www.youtube.com/watch?v=B-P4ysHSjCg + - title: Masahiro Sakurai · Flashing Effects + url: https://www.youtube.com/watch?v=Mmw39T_dBLI + + - id: treffer-im-ablauf + type: order + title: Ein Treffer von Anfang bis Ende + prompt: | + Bring die Ereignisse des echten Testszenen-Laufs aus der Lektion (Hitstop 4, Hitstun 12) in die Reihenfolge, in der sie passieren. + lang: text + lines: | + Die Hitbox des Spielers geht an + Das Signal meldet den Treffer, beide setzen hitstop_left + Spieler und Dummy stehen 4 Frames, der Blitz-Tween läuft + Die aktive Phase des Spielers läuft zu Ende + Der Spieler wechselt in die Erholung + Der Dummy ist wieder frei (WAIT) + Der Spieler ist wieder frei (MOVE) + explanation: | + Die Hitbox geht in Frame 20 an, der Treffer kommt in Frame 21. Die Frames 21 bis 24 stehen beide still, nur der Tween des Blitzes läuft, weil er nicht an `_physics_process` hängt. In 25 und 26 laufen die restlichen aktiven Frames, in 27 beginnt die Erholung. Der Dummy ist ab Frame 36 frei, der Spieler erst ab 41. Die Reihenfolge der letzten beiden Zeilen ist der Nachteil des Angreifers nach diesem Treffer. + + - id: kombo-mit-hitstop + type: output + title: Drei Treffer hintereinander + prompt: | + Platzhalter wie im Log der Lektion: Wind-up 8, aktiv 3, Erholung 14, Hitstop 4. Du greifst dreimal hintereinander an, jeder Druck ist gepuffert, jeder Angriff startet im ersten freien Frame und trifft den Dummy wie im Log. In welchem Frame steht `Spieler RECOVERY -> MOVE` nach dem dritten Angriff? Antworte mit der Zahl. + lang: text + code: | + 12 Spieler MOVE -> WINDUP + 41 Spieler RECOVERY -> MOVE + 41 Spieler MOVE -> WINDUP + … + ??? Spieler RECOVERY -> MOVE + expected: | + 99 + accept: + - Frame 99 + hints: + - Ein Angriff mit Treffer dauert 25 + 4 Frames. + explanation: | + Ein Angriff belegt 8 + 3 + 14 = 25 Frames, mit Treffer 4 mehr, also 29. Der erste läuft von 12 bis 40, der zweite von 41 bis 69, der dritte von 70 bis 98. In Frame 99 ist die Figur frei. Ohne Treffer wäre es Frame 87. Drei Treffer kosten also 12 Frames, eine Fünftelsekunde. In einer Kombo addiert sich jeder Hitstop. In der echten Testszene trifft übrigens nur der erste Angriff: Der Knockback schiebt den Dummy 11 Pixel weg, aus der Reichweite der nächsten. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: shake-sichtbar + type: output + title: Wie lange wackelt es sichtbar? + prompt: | + Das Modell aus der Lektion, aber trauma startet bei 0,8 und sinkt um 0,1 pro Frame. Was gibt das Programm aus? + lang: python + code: | + max_px = 4 + sichtbar = 0 + for t in range(80, 0, -10): + trauma = t / 100 + px = round(max_px * trauma ** 2) + if px >= 1: + sichtbar += 1 + print(sichtbar) + expected: | + 5 + hints: + - "Rechne `4 * trauma ** 2` für 0,8, 0,7, 0,6 … und runde." + explanation: | + Die Ausschläge sind 2,56, 1,96, 1,44, 1,0, 0,64, 0,36, 0,16 und 0,04 Pixel. Gerundet bleiben 3, 2, 1, 1, 1 und dann nur noch Nullen. Fünf von acht Frames wackelt die Kamera also sichtbar, die letzten drei steht sie, obwohl trauma noch über 0 ist. Das ist der Effekt von trauma² zusammen mit ganzen Pixeln: Das Ende des Shakes verschwindet früher, als der Wert vermuten lässt. + resources: + - title: Squirrel Eiserloh · Juicing Your Cameras With Math (GDC 2016) + url: https://www.youtube.com/watch?v=tu-Qe66AvtY + + - id: knockback-weg + type: output + title: Wie weit rutscht der Dummy? + prompt: | + So bremst der Dummy der Testszene im Hitstun: Start mit 120 px/s, in jedem Frame 10 px/s weniger, dann bewegt er sich um `v / 60`. Das Modell lässt Wände und Hitstop weg. Was gibt es aus? + lang: python + code: | + v = 120.0 # px/s beim Treffer + weg = 0.0 + while v > 0: + v = max(v - 10, 0) # move_toward + weg += v / 60 # ein Frame + print(round(weg, 1)) + expected: | + 11.0 + hints: + - Im ersten Frame ist `v` schon 110, im letzten 0. + explanation: | + Der Dummy bremst zuerst und bewegt sich dann, wie `move_toward` vor `move_and_slide()` in `enemy.gd`. Die Geschwindigkeiten sind 110, 100, … 10, zusammen 660 px/s über je eine Sechzigstelsekunde, also 11 Pixel. Das ist eine Rechnung nach dem Code, nicht in Godot gemessen. Bei 320 Pixeln Bildbreite sind 11 Pixel fast die Breite der Figur. Ob das mehr ist als Nijmans „a couple pixels“, ist eine Frage für dein Spiel. + resources: + - title: Godot Docs · Vector2.move_toward + url: https://docs.godotengine.org/en/stable/classes/class_vector2.html#class-vector2-method-move-toward + + - id: ms-und-frames + type: gap + title: Veröffentlichte Werte in Frames + prompt: | + Rechne um, bei 60 Frames pro Sekunde (ms = Frames × 1000 / 60). Runde auf ganze Zahlen. + lang: text + code: | + Vlambeer, sleep: 20 ms = ⟦1⟧ Frame + Dreamscaper: 115 ms = ⟦7⟧ Frames + Testszene: Hitstop 4 = ⟦67⟧ ms + Blitz des Dummys: 0,15 s = ⟦9⟧ Frames + gaps: + - label: 20 ms + hint: Ein Frame sind etwa 16,7 ms. + accept: ["1,2", "1.2"] + - label: 115 ms + hint: Cofino sagt es im Vortrag selbst. + accept: ["6,9", "6.9"] + - label: 4 Frames + hint: 4 × 1000 / 60 + accept: ["66,7", "66.7"] + - label: 0,15 s + hint: 0,15 × 60 + explanation: | + 20 ms sind 1,2 Frames, Vlambeers Pause dauert also gut einen Frame. 115 ms sind 6,9 Frames, Cofino rundet selbst auf „seven frames“. 4 Frames sind 66,7 ms. Der Blitz des Dummys läuft 9 Frames, also länger als der Hitstop. Rechne veröffentlichte Werte immer in Frames um, bevor du sie mit deinen vergleichst, und prüfe, ob sie für eine Figur oder für das ganze Spiel gelten. + resources: + - title: Jan Willem Nijman · The art of screenshake + url: https://www.youtube.com/watch?v=AJdEqssNZ-U + - title: Ian Cofino · Dreamscaper (GDC 2022) + url: https://www.youtube.com/watch?v=3Omb5exWpd4 + + - id: shake-geruest + type: gap + title: Trauma in die Kamera + prompt: | + Ergänze das Shake-Gerüst: trauma bleibt höchstens 1, die Stärke ist trauma², und der Ausschlag landet in der Eigenschaft von `Camera2D`, die die Doku für Shake vorsieht. + lang: gdscript + code: | + func add_trauma(menge: float) -> void: + trauma = ⟦minf⟧(trauma + menge, 1.0) + + + func _physics_process(_delta: float) -> void: + var s := ⟦trauma * trauma⟧ + var dir := _rauschen() + camera.⟦offset⟧ = (dir * max_px * s).round() + trauma = maxf(trauma - abbau, 0.0) + gaps: + - label: Obergrenze + hint: Die Funktion für das Kleinere von zwei `float`-Werten. + accept: ["min"] + - label: Stärke + hint: Eiserloh quadriert. + accept: ["trauma ** 2", "trauma ** 2.0", "pow(trauma, 2)", "pow(trauma, 2.0)"] + - label: Eigenschaft der Kamera + hint: Laut Doku „useful for looking around or camera shake animations“. + explanation: | + `minf` hält trauma bei 1, auch wenn mehrere harte Treffer hintereinander kommen. Die Stärke ist das Quadrat, damit kleiner Stress kaum wackelt. `offset` verschiebt das Bild der Kamera, ohne ihre Position zu ändern; die Kamera folgt ihrem Ziel also weiter. `.round()` rundet auf ganze Spielpixel. Ob dein Spiel das so will, ist eine Entscheidung, keine Pflicht. + resources: + - title: Godot Docs · Camera2D.offset + url: https://docs.godotengine.org/en/stable/classes/class_camera2d.html#class-camera2d-property-offset + + - id: vorhersage-hitstop + type: bug + title: Die Vorhersage stimmt fast + prompt: | + Du hast vor dem Testlauf diese Vorhersage geschrieben. Im Log stehen danach der Treffer in Frame 109 und `RECOVERY -> MOVE` in Frame 129, beides wie vorhergesagt. Eine Zeile der Vorhersage passt trotzdem nicht dazu. Finde sie und korrigiere sie. + lang: text + code: | + Angriff: Wind-up 8, aktiv 3, Erholung 14 + Hitstop 4, Treffer einen Frame nach ACTIVE + Start: MOVE -> WINDUP in Frame 100 + WINDUP -> ACTIVE 108 + Treffer gemeldet 109 + stillstehend 109 bis 112 + ACTIVE -> RECOVERY 111 + RECOVERY -> MOVE 129 + lines: [7] + fix: + - line: 7 + answers: + - ACTIVE -> RECOVERY 115 + explanation: | + 111 wäre der Wechsel ohne Treffer: 108 plus 3 aktive Frames. Der Hitstop hält die Frames 109 bis 112 an, danach laufen die aktiven Frames 2 und 3 in 113 und 114, und in 115 beginnt die Erholung. 115 plus 14 ergibt die 129 aus dem Log. Im echten Lauf der Testszene ist es genauso: ACTIVE in 20, RECOVERY in 27 statt 23. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: kamera-bleibt-versetzt + type: bug + title: Die Kamera kommt nicht zurück + prompt: | + Nach einem harten Treffer wackelt die Kamera, wie sie soll. Danach steht sie aber nicht wieder mittig, sondern bleibt um einige Pixel versetzt, nach jedem Treffer anders. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + var s := trauma * trauma + var dir := _rauschen() + camera.offset += (dir * max_px * s).round() + trauma = maxf(trauma - abbau, 0.0) + lines: [4] + fix: + - line: 4 + answers: + - camera.offset = (dir * max_px * s).round() + - camera.offset = (dir * s * max_px).round() + - camera.offset = (max_px * s * dir).round() + explanation: | + Mit `+=` addiert sich jeder Ausschlag zum vorigen. Die Kamera macht einen Zufallsweg, und wenn trauma bei 0 ankommt, bleibt sie dort stehen, wo der Weg zufällig endet. Mit `=` ist der Ausschlag in jedem Frame neu, und bei trauma 0 ist `offset` wieder `(0, 0)`. Ein Shake ist eine Abweichung von der Ruhelage, keine Bewegung. + resources: + - title: Godot Docs · Camera2D.offset + url: https://docs.godotengine.org/en/stable/classes/class_camera2d.html#class-camera2d-property-offset + + - id: hitstop-kosten + type: explain + title: Was der Treffer den Angreifer kostet + prompt: | + Erkläre an diesem echten Log, was der Hitstop von 4 Frames kostet und was er nicht verändert. + lang: text + code: | + 12 Spieler MOVE -> WINDUP + 20 Spieler WINDUP -> ACTIVE + 21 Dummy getroffen: Zone body … + 27 Spieler ACTIVE -> RECOVERY + 36 Dummy HITSTUN -> WAIT + 41 Spieler RECOVERY -> MOVE + points: + - Ohne Treffer begänne die Erholung in Frame 23 und die Figur wäre ab 37 frei; mit Treffer erst in 27 und ab 41. + - Beide stehen gleich lang still, deshalb ändert die Länge des Hitstops den Abstand nicht; der Angreifer ist so oder so 5 Frames im Nachteil. + - In einer Kombo addiert sich jeder Hitstop zur Gesamtdauer. + - Bei lokalem Hitstop laufen alle anderen weiter; Sakurai begrenzt ihn in Smash, weil ein dritter Spieler den Stillstand ausnutzen könnte. + explanation: | + Der Hitstop hält die aktive Phase des Spielers an, also endet sie in Frame 27 statt 23, und die Figur ist 4 Frames später frei. Der Dummy steht genauso lang, deshalb bleibt der Abstand zwischen beiden gleich: Mit diesen Platzhaltern hat der Angreifer nach dem Treffer 5 Frames Nachteil, mit Hitstop 8 ebenso. Was sich ändert, ist die Dauer jeder Aktion mit Treffer und damit das Tempo des Kampfs. Und während Angreifer und Ziel stehen, handelt jeder andere weiter. In Smash ist das der Grund für Sakurais Grenze, in der Draufsicht mit mehreren Gegnern gilt dieselbe Rechnung. + resources: + - title: Source Gaming · Sakurais Kolumne zum Hitstop (Fan-Übersetzung) + url: https://sourcegaming.info/2015/11/11/thoughts-on-hitstop-sakurais-famitsu-column-vol-490-1/ + + - id: clip-hitstop-shake + type: practice + title: "Clip-Analyse: Hitstop und Shake bei Sakurai" + prompt: | + Sieh dir [Eight Hit Stop Techniques](https://www.youtube.com/watch?v=tycbMSjDDLg) (5:32) und [Screen Shake](https://www.youtube.com/watch?v=2JXR7IASSog) (3:39) an. Halte bei jeder Technik an. Notiere zu jeder in einem Satz, was im Bild passiert, und ob sie in einer Draufsicht ohne Boden und Luft anders funktionieren müsste. Die zweite Frage beantwortest du für dich, es gibt keine richtige Antwort. + lang: text + solution: | + Beispiel für die Form, nicht für den Inhalt deiner Notizen: + + Technik: Zittern seitlich am Boden, + in der Luft in alle Richtungen + Im Bild: Figur am Boden wackelt nur + links-rechts. + Draufsicht: Es gibt kein Oben/Unten für Luft. + Offen: Zittern in Schlagrichtung? Immer seitlich? + + Technik: Shake klein nur senkrecht + Im Bild: kleiner Treffer, Bild hüpft auf und ab. + Draufsicht: senkrecht heißt hier + „nach oben im Bild“, nicht „nach oben + in der Welt“. + Offen: Muster je Angriffsklasse wie bei Sakurai? + checklist: + - Du hast zu jeder Technik aus beiden Clips eine Zeile notiert, was im Bild passiert. + - Bei mindestens drei Techniken hast du notiert, ob sie sich auf eine Draufsicht übertragen lässt und was offen bleibt. + - Du hast notiert, an welchen Stellen Sakurai eine Zahl nennt und an welchen nicht. + - Deine offenen Fragen sind Fragen, keine Festlegungen für dein Spiel. + explanation: | + Sakurais Clips zeigen Regeln aus einem Spiel mit Seitenansicht. Manche lassen sich direkt übernehmen, etwa „Hurtboxen bleiben stehen“ oder „Zittern klingt ab“. Andere hängen an Boden und Luft, und die gibt es in der Draufsicht nicht. Diese Stellen sind die interessanten: Dort musst du für dein Spiel eine eigene Regel finden. Die Übung sammelt sie, entscheiden musst du später. + resources: + - title: Masahiro Sakurai · Eight Hit Stop Techniques + url: https://www.youtube.com/watch?v=tycbMSjDDLg + - title: Masahiro Sakurai · Screen Shake + url: https://www.youtube.com/watch?v=2JXR7IASSog + + - id: feedback-notiz + type: practice + title: Ein Feedback-Plan für den Trainingsdummy + prompt: | + Schreib eine kurze Notiz, welches Trefferfeedback du am Trainingsdummy der Testszene ausprobieren willst. Für jedes Mittel: an oder aus, ein Platzhalterwert und was du erwartest. Leg fest, in welcher Reihenfolge du die Mittel einzeln testest. Getestet wird später am PC. + lang: text + solution: | + Beispiel für die Form, ausdrücklich keine Vorgabe: + + Trainingsdummy, Angriff A (Platzhalter) + Mittel Wert Erwartung + Hitstop 4 Frames Treffer fällt mir auf + Shake aus – + Blitz 9 Frames sehe, wer getroffen ist + Knockback 120 px/s Dummy rückt aus Reichweite? + Ton aus – + + Reihenfolge der Tests: + 1. Hitstop 0 gegen 4, sonst alles gleich + 2. danach Shake an/aus + Preis, den ich beobachte: Angriff 4 Frames länger + checklist: + - Jedes Mittel hat einen Wert mit Einheit oder steht ausdrücklich auf „aus“. + - Zu jedem Mittel, das an ist, steht eine Erwartung, aufgeschrieben vor dem Test. + - Zu mindestens einem Mittel hast du den Preis notiert, den du beim Test beobachten willst. + - Jeder geplante Test ändert genau ein Mittel. + - Die Werte sind deine Platzhalter; du hast keinen Wert als „richtig“ markiert. + explanation: | + Die Notiz trennt Plan und Urteil. Vorher schreibst du auf, was du erwartest, nachher vergleichst du mit dem, was du gespielt hast. Ein Mittel pro Test, sonst weißt du nicht, welches den Unterschied gemacht hat. Die Werte sind deine; der Kurs gibt dir keine. Wie aus der Notiz ein sauberer Vergleich wird, steht in [Tuning als Experiment](/combat/tuning), und wie du sie einem Coding-Agenten gibst, in [Kampfmechanik als Vorgabe](/combat/vorgaben). + resources: + - title: Mike Ambinder · Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf diff --git a/content/combat/modules/tuning.yaml b/content/combat/modules/tuning.yaml new file mode 100644 index 0000000..a8105c9 --- /dev/null +++ b/content/combat/modules/tuning.yaml @@ -0,0 +1,649 @@ +id: tuning +title: Tuning als Experiment +level: 3 +minutes: 180 +summary: Kampfwerte als Experiment abstimmen – Hypothese vorher, genau eine Größe ändern, Variante A gegen B mit Mash-Bot und Reaktionstest vergleichen, Treffer Bild für Bild nachvollziehen und jede Runde im Protokoll festhalten. +goals: + - Du schreibst eine Hypothese mit Ursache und erwarteter Wirkung auf, bevor du einen Wert änderst. + - Du legst Varianten als `.tres`-Dateien an und prüfst an den Kopfzeilen des Logs, dass sie sich in genau einer Größe unterscheiden. + - Du testest jede Variante getrennt mit dem Mash-Bot und von Hand mit Reaktion auf den Telegraph und begründest, warum es beide Tests braucht. + - Du findest mit Log, Einzelbild und Movie Maker den Frame, in dem dich ein Treffer erwischt hat. + - Du führst ein Iterationsprotokoll und lässt andere spielen, ohne ihnen das Muster zu erklären. +resources: + - title: Valve's Approach to Playtesting (Mike Ambinder, GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + - title: Honoring the blade – Lethality Contract in Ghost of Tsushima (PlayStation Blog) + url: https://blog.playstation.com/?p=344024 + - title: Godot Docs · Creating movies (Movie Maker) + url: https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html + - title: Godot Docs · Resources + url: https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html + - title: Playtesting – Avoiding Evil Data (Adriaan de Jongh, GDC 2017) + url: https://www.youtube.com/watch?v=6EUeYu0aPn4 +lesson: | + > [!NOTE] Am PC + > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. + + Der Trainingsgegner erwischt dich zu oft. Du verlängerst seinen Telegraph, gibst deinem Ausweichen zwei i-Frames mehr und kürzt seine Erholung. Danach fühlt sich der Kampf besser an. Welche der drei Änderungen hat gewirkt? Vielleicht nur eine, vielleicht hebt eine die andere auf. Du weißt es nicht, und beim nächsten Gegner fängst du wieder bei null an. + + Dieses Modul macht aus dem Abstimmen (Tuning) ein Experiment: eine Vermutung vorher, eine Änderung, ein Vergleich, ein Protokoll. Es setzt die Szene aus [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen) voraus. Neu ist ein Node, der Version und Werte ins Log schreibt und stumpfes Angreifen nachstellt. Der Code ist Gerüst. Welche Werte du ausprobierst und ob dir ein Ergebnis gefällt, entscheidest du. Alle Zahlen hier sind Platzhalter der Testszene. + + ## Hypothese, eine Größe, Vergleich + + Valve beschreibt seinen Designprozess in vier Zeilen: „Game designs are hypotheses“, „Playtests are experiments“, „Evaluate designs off playtest results“, „Repeat“. Ein Designexperiment heißt dort „Compare two or more conditions“, Daten sammeln und „Verify hypothesis“ ([Valve's Approach to Playtesting, GDC 2009](https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf)). Für eine Runde Tuning in der Testszene wird daraus: + + 1. **Hypothese.** Bevor du etwas änderst, schreibst du Ursache und erwartete Wirkung auf: „Der Dummy kommt gegen Dauerdrücken nie zum Schlag, weil ihn jeder Treffer im Telegraph unterbricht. Wird der Telegraph kürzer, erwarte ich, dass er den Bot trifft.“ + 2. **Eine Größe.** Du änderst genau einen Wert. + 3. **Vergleich.** Variante A (vorher) gegen Variante B (nachher), mit denselben Tests. + 4. **Beobachtung.** Das Log liefert die Fakten, deine Notiz den Eindruck. Beides getrennt. + 5. **Entscheidung.** B behalten, verwerfen oder weiter untersuchen. + 6. **Offene Fragen.** Was die Runde nicht beantwortet hat, wird die nächste Hypothese. + + „Eine Größe“ heißt in der Testszene: ein Feld einer `AttackData` (etwa `windup` in `gegner_b.tres`), ein Ausweich-Wert der Figur (`invulnerable_to`) oder ein Wert des Gegners (`wait_frames`, `interruptible`). Nie Spieler- und Gegnerwerte zugleich. Eine Größe darf mehrere Folgen haben: Mehr `hitstop` verlängert die aktive Phase des Angreifers und hält das Ziel länger an. Diese Folgen sagst du in der Hypothese voraus. Zwei Größen dagegen kannst du im Ergebnis nicht mehr trennen. + + Ein Gefühl ist noch keine Größe. Bei Ghost of Tsushima meldeten Tester früh, die Gegner seien „sword sponges“. Theodore Fishmans Lieblingszitat aus diesen Tests: „I felt like I was hitting enemies with a foam bat.“ Sucker Punch setzte eine harte Obergrenze für „hits to kill“ über alle Gegner. Um trotzdem Herausforderung zu schaffen, nennt Fishman als Hebel die Verteidigung der Gegner, das Tempo ihrer Angriffe, das Timing für Blocken, Parieren und Ausweichen und den Schaden; mehr HP nur als „Last resort“ ([Honoring the blade, PlayStation Blog](https://blog.playstation.com/?p=344024)). Das ist die Arbeit vor jeder Hypothese: Welcher einzelne Wert steckt hinter dem Gefühl? + + > [!MERKE] + > Hypothese vor der Änderung, eine Größe pro Runde, A gegen B mit denselben Tests. Was du erst nach dem Test aufschreibst, ist keine Vorhersage mehr. + + Patchnotes zeigen das Ergebnis, nicht das Experiment. Für Thanatos in Hades steht in einer Zeile: „reduced frequency of his area attack; also increased preattack duration and recovery time“ ([Hades: The Big Bad Update](https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/)). Drei Änderungen an einem Gegner; wie Supergiant sie getestet hat, steht dort nicht. Ausliefern darfst du gebündelt, testen nicht. + + ## Varianten als Dateien und Werte im Log + + Die Frame-Daten liegen seit [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) in `AttackData`-Resources. Als `.tres` gespeichert ist eine Resource eine Textdatei, die sich gut versionieren lässt ([Resources](https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html)). Für ein Experiment heißt das: + + - `gegner_a.tres` ist die Ausgangslage. Du änderst sie nicht mehr. + - `gegner_b.tres` entsteht als Kopie im Dateisystem-Dock. Darin änderst du genau ein Feld. + - Zum Umschalten ziehst du im Inspektor die andere Datei in `attacks` des Dummys und startest neu. + + Greg Kasavin erzählt, dass Supergiant für Hades am Ende eines Meilensteins den Code sperrt und trotzdem noch „make data changes, and fine tune things“ kann ([Game Developer, 2019](https://www.gamedeveloper.com/design/supergiant-s-fourth-outing-i-hades-i-introduces-a-more-mature-organized-dev-process)). Das geht nur, weil die Werte Daten sind und nicht im Code stehen. + + Damit du später weißt, was du getestet hast, schreibt ein Versuch-Node zu Beginn jedes Laufs die Godot-Version und alle Werte ins Log. Häng einen `Node` unter `Main`, gib ihm dieses Skript und zieh Figur und Dummy in seine Felder: + + ```gdscript title=versuch.gd + extends Node + + ## Versuchsleiter der Testszene. Technisches Gerüst, keine Designentscheidung: + ## schreibt zu Beginn Godot-Version, Varianten und Werte ins Log und drückt auf + ## Wunsch alle N Frames attack (Mash-Bot), damit stumpfes Angreifen reproduzierbar ist. + + @export var player: CharacterBody2D + @export var enemy: CharacterBody2D + @export var mash := false ## an: der Bot greift stumpf an, Hände weg + @export_range(1, 60) var mash_every := 4 ## alle so viele Frames ein Druck + + var mash_frame := 0 + + + func _ready() -> void: + HitLog.add("Godot %s" % Engine.get_version_info().string) + HitLog.add("Spieler %s, Puffer %d" % [_describe(player.attack), player.input_buffer]) + HitLog.add("Ausweichen %d Frames, i-Frames %d-%d, Tempo %.0f, Erholung %d" % [player.dodge_frames, player.invulnerable_from, player.invulnerable_to, player.dodge_speed, player.dodge_recovery]) + for a: AttackData in enemy.attacks: + HitLog.add("%s %s" % [enemy.name, _describe(a)]) + HitLog.add("%s wait %d, interruptible %s" % [enemy.name, enemy.wait_frames, enemy.interruptible]) + HitLog.add("Mash-Bot %s" % ("alle %d Frames" % mash_every if mash else "aus")) + + + func _physics_process(_delta: float) -> void: + if not mash: + return + if Input.is_action_pressed(&"attack"): + Input.action_release(&"attack") + mash_frame += 1 + if mash_frame >= mash_every: + mash_frame = 0 + Input.action_press(&"attack") + + + func _describe(a: AttackData) -> String: + return "%s: windup %d, active %d, recovery %d, hitstop %d, hitstun %d, knockback %.0f" % [a.resource_path.get_file(), a.windup, a.active, a.recovery, a.hitstop, a.hitstun, a.knockback] + ``` + + `Engine.get_version_info().string` fasst Version, Status und Build in einem Text zusammen ([Engine.get_version_info](https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-method-get-version-info)). `resource_path.get_file()` liefert den Dateinamen der Variante. Die Kopfzeilen eines echten Laufs mit Godot 4.7.2: + + ```text + 0 Godot 4.7.2-stable (official) + 0 Spieler angriff_kb0.tres: windup 8, active 3, recovery 14, hitstop 4, hitstun 12, knockback 0, Puffer 6 + 0 Ausweichen 18 Frames, i-Frames 2-12, Tempo 220, Erholung 8 + 0 Dummy gegner_a.tres: windup 20, active 4, recovery 30, hitstop 4, hitstun 12, knockback 120 + 0 Dummy wait 10, interruptible true + 0 Mash-Bot alle 4 Frames + ``` + + `angriff_kb0.tres` ist eine Kopie von `angriff_a.tres` mit `knockback` 0. Mit den Platzhaltern stößt der erste Treffer den Dummy aus der Reichweite. Im Lauf mit Godot 4.7.2 traf danach keiner mehr, der Dummy schlug dreimal in die Luft. Für die Läufe in dieser Lektion bleibt deshalb der Rückstoß aus, in A wie in B. + + Reproduzierbare Läufe startest du im Terminal; `godot` steht für dein Godot-Binary. `--fixed-fps 60` schaltet die Echtzeit ab, jeder Durchlauf ist genau ein Physik-Frame, und `--quit-after 240` beendet nach 240 Durchläufen: + + ```console + $ godot --headless --path . --fixed-fps 60 --quit-after 240 > lauf_a.log + $ godot --headless --path . --fixed-fps 60 --quit-after 240 > lauf_b.log + $ diff lauf_a.log lauf_b.log | head -4 + 6c6 + < 0 Dummy gegner_a.tres: windup 20, active 4, recovery 30, hitstop 4, hitstun 12, knockback 120 + --- + > 0 Dummy gegner_b.tres: windup 2, active 4, recovery 30, hitstop 4, hitstun 12, knockback 120 + ``` + + Zwischen den Läufen hast du im Inspektor `gegner_b.tres` eingesetzt und die Szene gespeichert. In den Kopfzeilen weicht genau eine Zeile ab, mit genau einem Wert: Damit ist belegt, dass B sich in einer Größe von A unterscheidet. Zwei Läufe mit derselben Variante ergaben im Test zeichengleiche Logs. + + ## Zwei Testarten: stumpf drücken und bewusst reagieren + + Wer stumpf angreift, hämmert auf die Angriffstaste und achtet nicht auf den Gegner (Button-Mashing). Wer bewusst reagiert, wartet auf die gelbe Fläche, weicht aus und schlägt in die Erholung. Ein Muster, das man mit Dauerdrücken schlägt, belohnt kein Lesen. Deshalb testest du jede Variante zweimal, stumpf und bewusst, und notierst beide Ergebnisse getrennt. + + Den stumpfen Test übernimmt der Mash-Bot im Versuch-Node. Mit `mash` an drückt er alle `mash_every` Frames `attack` und lässt im nächsten Frame wieder los. `Input.action_press()` simuliert einen Druck, den `is_action_just_pressed()` sieht ([Input.action_press](https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-action-press)). Gedrückt halten reicht nicht: `is_action_just_pressed()` ist nur in dem Frame wahr, in dem die Taste runtergeht. Gemessen mit Godot 4.7.2: Die Figur sieht jeden Druck des Bots einen Frame später, egal wo der Node im Baum hängt. Bei `mash_every = 4` steht `Druck attack` in den Frames 5, 9, 13 und so weiter. + + Ein Ausschnitt aus Lauf A mit dem Bot, die 59 Zeilen `Druck attack` mit `grep -v` herausgefiltert: + + ```text + 5 Spieler MOVE -> WINDUP + 10 Dummy WAIT -> TELEGRAPH + 13 Spieler WINDUP -> ACTIVE + 14 Dummy getroffen: Zone body, Schaden 1.0, in TELEGRAPH Frame 4 + 14 Dummy TELEGRAPH -> HITSTUN + 20 Spieler ACTIVE -> RECOVERY + 29 Dummy HITSTUN -> WAIT + 34 Spieler RECOVERY -> MOVE + 34 Spieler MOVE -> WINDUP + 39 Dummy WAIT -> TELEGRAPH + 42 Spieler WINDUP -> ACTIVE + 43 Dummy getroffen: Zone body, Schaden 1.0, in TELEGRAPH Frame 4 + 43 Dummy TELEGRAPH -> HITSTUN + ``` + + Das geht so weiter: In 240 Frames steht achtmal `Dummy getroffen`, immer in Frame 4 des Telegraphs, und nie `Dummy TELEGRAPH -> ACTIVE`. Der Dummy steckt in einem **Stun-Lock**: Er kommt aus dem Hitstun (Frames, in denen das getroffene Ziel nicht handeln kann) nie bis zu seinem Angriff. Die Rechnung steht im Log. Sein Telegraph beginnt 25 Frames nach einem Treffer (14 → 39), der nächste Treffer kommt nach 29 (14 → 43). Mit 20 Frames Telegraph fehlen ihm 16 Frames. + + Variante B kürzt nur den Telegraph, `windup` 20 → 2: + + ```text + 5 Spieler MOVE -> WINDUP + 10 Dummy WAIT -> TELEGRAPH + 12 Dummy TELEGRAPH -> ACTIVE + 13 Spieler getroffen in WINDUP, Frame 8 + 13 Spieler WINDUP -> HITSTUN + ``` + + B trifft den Bot in Frame 13, genau dort, wo in A seine aktive Phase begann. Danach stößt der Treffer die Figur aus der Reichweite, bis Frame 240 trifft keiner mehr. Den Mash-Test besteht B also. Ob B Lesen belohnt, sagt dieser Test nicht: Zwischen dem ersten gelben Bild und der aktiven Hitbox liegen 2 Frames, 33 Millisekunden. Ob du darauf reagierst oder nur den Takt rätst, zeigt erst der Reaktionstest. + + Den machst du von Hand: `mash` aus, Hände an die Tasten, und du handelst nur nach dem Telegraph. Zähle Anläufe und Treffer gegen dich. Das Log zählt mit: `grep -c "Spieler getroffen" hand_a.log` und `grep -c "TELEGRAPH -> ACTIVE" hand_a.log` geben Treffer und gegnerische Angriffe. Einen Bot für diesen Test baust du nicht. Ein Bot reagiert so schnell, wie du ihn programmierst, und sagt nichts darüber, ob ein Mensch die gelbe Fläche liest. + + > [!PRAXIS] + > Coin Crew Games hat für Escape Academy über 200 Playtest-Sitzungen durchgeführt und den Vortrag dazu „Never Skip Playtest Day!“ genannt ([GDC 2023](https://gdcvault.com/play/1028825/Independent-Games-Summit-Never-Skip)). Plane Tests als festen Termin in jeder Runde, nicht als Abschluss, wenn alles fertig ist. Der Mash-Bot kostet dich dabei nur einen Haken im Inspektor. + + ## Warum hat es mich getroffen? Log, Einzelbild, Movie Maker + + Fang beim Log an. `13 Spieler getroffen in WINDUP, Frame 8` nennt Zustand und Frame. Lies rückwärts: Der Dummy wechselte in Frame 12 nach `ACTIVE`, seine Hitbox war ab da an, und der Treffer kam einen Frame später an. Die Figur hatte acht Wind-up-Frames hinter sich und wäre in Frame 13 selbst aktiv geworden. Bei einem Treffer im Reaktionstest liest du genauso ab, ob dein `Druck dodge` zu spät kam oder ob der Code einen Frame verschluckt hat. + + Reicht das Log nicht, schau Bild für Bild. **Pause** (F9) hält das Spiel an, „it will still render and process input“; **Nächster Frame** (F10) rückt einen Frame vor; wie weit ein Schritt geht, prüfst du am Frame-Zähler der Debug-Anzeige ([Game embedding](https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10)). Mit *Debug → Kollisionsformen sichtbar* zeichnet Godot dazu jede Form ([Debugging tools](https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes)). + + Für ganze Läufe gibt es den **Movie Maker**. Godot schreibt dann jedes Bild als Datei, nicht in Echtzeit, aber ohne verlorene Bilder ([Creating movies](https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html)). Mit `.png` als Endung entsteht eine PNG-Folge, ein Bild pro Frame, plus eine WAV-Datei. `--debug-collisions` entspricht dem Menüpunkt für die Kollisionsformen: + + ```console + $ mkdir -p /tmp/lauf_a + $ godot --path . --debug-collisions --write-movie /tmp/lauf_a/bild.png --fixed-fps 60 --quit-after 240 + Movie Maker mode enabled, recording movie in 320×180 @ 60 FPS... + … + Done recording movie at path: /tmp/lauf_a/bild.png + 240 frames at 60 FPS (movie length: 00:00:04:00), recorded in 00:00:04 (100% of real-time speed). + $ ls /tmp/lauf_a | head -2 + bild00000000.png + bild00000001.png + ``` + + Das Log dieses Laufs war zeichengleich mit dem Lauf ohne Bilder. Im Lauf mit Godot 4.7.2 zeigte `bild00000012.png` den Stand nach Physik-Frame 13 und `bild00000013.png` den nach Frame 14, mit aufblitzendem Dummy: Bildnummer = Log-Frame − 1. Prüf das bei dir einmal an einem Treffer. Die Debug-Anzeige steht in jedem Bild, mit Zustand und Frame der Figur. + + > [!WARNING] Movie Maker braucht Fenster und Ordner + > Mit `--headless` stürzte Godot 4.7.2 im Test ab, ohne ein Bild zu schreiben. Fehlt der Zielordner, meldet Godot nur `Condition "d.is_null()" is true. Returning: FAILED` und schreibt ebenfalls nichts. Leg den Ordner vorher mit `mkdir -p` an. + + ## Das Iterationsprotokoll + + Jede Runde bekommt einen Eintrag. Die Vorlage: + + ```text + Datum ‹TT.MM.JJJJ› + Godot ‹aus der ersten Kopfzeile des Logs› + Ausgangslage ‹Kopfzeilen von Lauf A› + Hypothese ‹Ursache› – darum erwarte ich ‹Wirkung› + Größe alt -> neu ‹Datei, Feld, alter Wert -> neuer Wert› + Erwartung Mash-Test: ‹…› Reaktionstest: ‹…› + Messung (Log) Mash-Bot A und B: ‹Zählung aus grep› + Reaktionstest A und B: ‹Anläufe, Treffer gegen dich› + Eindruck ‹eigene Worte, was du beim Spielen bemerkt hast› + Entscheidung ‹behalten, verwerfen oder weiter untersuchen› + Offene Frage ‹was diese Runde nicht beantwortet hat› + ``` + + Ein ausgefülltes Beispiel für den neutralen Trainingsgegner. Es zeigt die Form, keine Empfehlung: Die Werte sind Platzhalter, die Mash-Zeilen sind echte Messungen mit Godot 4.7.2, und was in ‹…› steht, trägst du selbst ein. + + ```text + Datum 16.10.2026 + Godot 4.7.2-stable (official) + Ausgangslage Spieler angriff_kb0.tres, Dummy gegner_a.tres, + wait 10, interruptible true + Hypothese Gegen Dauerdrücken kommt der Dummy nie zum Schlag: + Sein Telegraph beginnt 25 Frames nach einem Treffer, + der nächste Treffer kommt nach 29. Ist der Telegraph + kurz genug, trifft er vor meinem nächsten Schlag. + Größe alt -> neu gegner_b.tres windup 20 -> 2, sonst nichts + Erwartung Mash-Test: B trifft den Bot mindestens einmal. + Reaktionstest: Mit B werde ich öfter getroffen als mit A. + Messung (Log) Mash-Bot alle 4 Frames, 240 Frames: + A: 8 x Dummy getroffen, 0 x Spieler getroffen, + 0 x Dummy TELEGRAPH -> ACTIVE + B: 0 x Dummy getroffen, 1 x Spieler getroffen + (Frame 13, in WINDUP Frame 8), 5 x TELEGRAPH -> ACTIVE + Reaktionstest von Hand: A ‹…›, B ‹…› + Eindruck ‹deine Worte› + Entscheidung ‹deine› + Offene Frage Bricht ein anderer einzelner Wert den Stun-Lock, ohne + den Telegraph zu kürzen? Je eine Runde für + interruptible und für wait_frames. + ``` + + Die Version steht oben, weil sich Timing zwischen Godot-Versionen ändern kann. Die Ausgangslage kopierst du aus den Kopfzeilen, nicht aus dem Gedächtnis. Die Messung bleibt eine Zählung; was du dabei denkst, gehört in „Eindruck“. + + ## Grenzen des eigenen Tests + + Du bist ein schlechter Tester deines eigenen Musters. Du kennst den Telegraph, und nach zwanzig Anläufen drückst du im Takt statt auf das Gelb. Der Platzhalter-Gegner aus dem letzten Modul beginnt seinen Telegraph alle 64 Frames (Log: 10, 74). Diesen Takt lernst du, ohne es zu merken. Dein Reaktionstest misst dann Gewöhnung, nicht Lesbarkeit. + + Kleine Stichproben täuschen. Bei drei Anläufen macht ein einziger Fehlgriff 33 Prozentpunkte aus. Notiere jeden Anlauf einzeln, nicht nur die Summe; Valve warnt: „Averages hide extreme examples.“ + + Lass deshalb andere spielen. Erkläre nur die Tasten, nicht das Muster. Valve nennt als Stärke der direkten Beobachtung, dass zählt, was Leute tun, nicht was sie sagen, und hält über Befragungen fest: „People don't know why they do what they do.“ Zugleich gilt: „Presence of observers can bias results.“ Setz dich zur Seite, kommentiere nicht, und frag erst nach dem Spiel. Adriaan de Jongh zeigt aus sechs Jahren Playtests mit acht Spielen, wie er unbrauchbare oder verwirrende Daten vermeidet ([Avoiding Evil Data, GDC 2017](https://www.youtube.com/watch?v=6EUeYu0aPn4)). Größere Studios haben dafür eigene Teams ([User Research on Destiny, GDC 2015](https://gdcvault.com/play/1022354/User-Research-on)). + + > [!INTERVIEW] + > „Woran erkennst du, ob eine Balance-Änderung gewirkt hat?“ Gute Antwort: Schreib vorher auf, was du erwartest und warum, ändere genau einen Wert und vergleiche A und B mit denselben Tests, einem reproduzierbaren Bot-Lauf und einem Test von Hand. Fakten kommen aus dem Log, den Eindruck notierst du getrennt. Danach lässt du andere spielen und beobachtest, was sie tun. + + ## Häufige Fehler + + - **Hypothese erst nach dem Test.** Dann passt jede Erklärung zum Ergebnis. Schreib sie auf, bevor du `gegner_b.tres` anlegst. + - **Zwei Größen in einer Runde.** Vor allem Spieler und Gegner zugleich. Das Ergebnis lässt sich keinem Wert zuordnen. + - **A überschrieben.** Ohne unveränderte Ausgangsdatei gibt es nichts zu vergleichen. Erst kopieren, dann ändern. + - **Eine Datei an zwei Stellen.** Ziehen Figur und Dummy dieselbe `.tres`, änderst du beide: Godot lädt eine Datei nur einmal ([Resources](https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html)). Mit Godot 4.7.2 gemessen: `recovery` an der Figur auf 20 gesetzt, der Dummy hatte danach ebenfalls 20. Die Kopfzeilen zeigen dann bei beiden denselben Dateinamen. + - **Werte eingebettet.** Steht in der Kopfzeile `main.tscn::…` statt eines Dateinamens, steckt die Resource in der Szene. Speichere sie als eigene Datei. + - **Nur ein Test.** Nur der Bot übersieht, ob Lesen sich lohnt. Nur von Hand misst du irgendwann deine Gewöhnung. + - **Der Bot lässt nicht los.** Ohne `action_release()` bleibt `attack` gedrückt, und im Log steht ein einziges `Druck attack`. + - **Movie Maker schreibt nichts.** Zielordner fehlt, oder Godot läuft mit `--headless`. + + ## Zusammenfassung + + - Tuning ist ein Experiment: Hypothese mit Ursache und Erwartung, eine Größe, A gegen B, Beobachtung, Entscheidung, offene Frage. + - Eine Größe ist ein Feld einer `AttackData`, ein Ausweich-Wert oder ein Gegnerwert. Nie Spieler und Gegner in derselben Runde. + - Varianten liegen als eigene `.tres`-Dateien vor. Die Kopfzeilen des Logs beweisen, welche Variante lief und dass sich nur ein Wert unterscheidet. + - Der Mash-Bot macht stumpfes Angreifen reproduzierbar, der Reaktionstest von Hand zeigt, ob Lesen sich lohnt. Erst beide zusammen sagen etwas über ein Muster. + - Warum dich etwas getroffen hat, liest du zuerst im Log, dann Bild für Bild mit F9/F10 oder in der PNG-Folge des Movie Makers. + - Das Protokoll trennt Messung und Eindruck. Die Entscheidung triffst du. + - Dein eigener Test ist voreingenommen. Andere spielen lassen, beobachten, was sie tun, und erst danach fragen. +exercises: + - id: eine-groesse + type: choice + title: Was ist eine Größe? + prompt: | + Du willst prüfen, ob der Trainingsgegner gegen Dauerdrücken zu leicht ist. Welche Änderungen sind jeweils genau eine Größe? + options: + - text: "`gegner_b.tres` ist eine Kopie von `gegner_a.tres`, nur `windup` steht auf 12." + correct: true + why: Ein Feld in einer eigenen Datei, alles andere gleich. So sieht eine saubere Variante B aus. + - text: "`wait_frames` des Dummys von 10 auf 30, alle Dateien unverändert." + correct: true + why: Ein Wert des Gegners. Die Kopfzeile `Dummy wait 30` belegt den Unterschied. + - text: "`hitstop` in `angriff_kb0.tres` von 4 auf 8, obwohl dadurch Spieler und Dummy länger stillstehen." + correct: true + why: Eine Größe darf mehrere Folgen haben. Du sagst sie in der Hypothese voraus und prüfst sie im Log. + - text: "`windup` in `gegner_b.tres` kürzer und `invulnerable_to` der Figur länger, damit das Ausweichen fair bleibt." + correct: false + why: Zwei Größen, eine beim Gegner, eine bei der Figur. Welche das Ergebnis bewirkt hat, lässt sich danach nicht sagen. + - text: Figur und Dummy nutzen dieselbe `angriff_a.tres`; du änderst darin nur `recovery`. + correct: false + why: Ein Feld, aber zwei Figuren. Godot lädt die Datei nur einmal, beide bekommen den neuen Wert. Mit Godot 4.7.2 nachgemessen. + explanation: | + Eine Größe ist ein einzelner Wert an einer Stelle: ein Feld einer `AttackData`, ein Ausweich-Wert oder ein Gegnerwert. Dass ein Wert mehrere Folgen hat, ist normal; der Hitstop verlängert die Zustände beider Beteiligten. Kritisch ist, wenn zwei unabhängige Werte zugleich wechseln, auch versteckt über eine geteilte Datei. Dann gibt es zwei mögliche Ursachen für ein Ergebnis. + resources: + - title: Godot Docs · Resources + url: https://docs.godotengine.org/en/stable/tutorials/scripting/resources.html + + - id: mash-log-lesen + type: choice + title: Was sagt der Bot? + prompt: | + Der Mash-Bot läuft 240 Frames gegen Variante A. Das Log zeigt achtmal `Dummy getroffen`, nie `Spieler getroffen` und nie `Dummy TELEGRAPH -> ACTIVE`. Welche Schlüsse trägt dieses Log? + options: + - text: Gegen stumpfes Angreifen kommt der Dummy mit diesen Werten nie zum Schlag. + correct: true + why: Ohne `TELEGRAPH -> ACTIVE` gibt es keinen gegnerischen Angriff. Das ist eine Zählung, keine Deutung. + - text: Gegen A lohnt sich Lesen nicht, solange Dauerdrücken schon ohne Gegentreffer gewinnt. + correct: true + why: Wer nur drückt, bekommt hier alles. Ein Spieler, der auf den Telegraph wartet, hat keinen Vorteil davon. + - text: A ist zu leicht und muss geändert werden. + correct: false + why: Ob das Muster so bleiben soll, ist eine Designentscheidung. Das Log beschreibt, was passiert, nicht, was passieren soll. + - text: Der Bot zeigt, wie ein Mensch A spielt. + correct: false + why: Der Bot steht still und drückt alle 4 Frames. Ein Mensch läuft, zögert und reagiert. Dafür gibt es den Reaktionstest. + explanation: | + Das Log belegt einen Stun-Lock: Jeder Treffer schickt den Dummy in den Hitstun, bevor sein Telegraph endet. Daraus folgt, dass stumpfes Drücken gegen A gewinnt und Lesen nichts bringt. Ob du das ändern willst, entscheidest du. Wie sich ein Mensch gegen A schlägt, misst du von Hand. + resources: + - title: Valve's Approach to Playtesting (PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: runde-ablauf + type: order + title: Eine Runde in der richtigen Reihenfolge + prompt: | + Bring die Schritte einer Tuning-Runde in eine sinnvolle Reihenfolge. + lang: text + lines: | + Hypothese mit Ursache und erwarteter Wirkung aufschreiben + gegner_a.tres im Dateisystem-Dock als gegner_b.tres duplizieren + In gegner_b.tres genau ein Feld ändern + Mash-Test mit A und mit B laufen lassen und die Logs speichern + In den Kopfzeilen prüfen, dass nur dieser eine Wert abweicht + Reaktionstest von Hand mit A und mit B + Entscheidung und offene Frage ins Protokoll schreiben + alternatives: + - [1, 2, 3, 4, 6, 5, 7] + - [1, 2, 3, 6, 4, 5, 7] + explanation: | + Die Hypothese steht am Anfang, noch vor der Kopie. Wer erst ändert und dann begründet, passt die Erklärung dem Ergebnis an. Die Kopie kommt vor der Änderung, damit A unverändert bleibt. Mash- und Reaktionstest sind voneinander unabhängig, ihre Reihenfolge ist egal. Die Kopfzeilen prüfst du, sobald die Logs da sind. Die Entscheidung kommt zuletzt, wenn alle Messungen vorliegen. + resources: + - title: Valve's Approach to Playtesting (PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: mash-takt + type: output + title: Der Takt des Bots + prompt: | + Der Versuch-Node hat `mash = true` und `mash_every = 6`. Die Physik-Frames zählen ab 1. Ein Druck mit `Input.action_press()` zählt für `is_action_just_pressed()` der Figur im nächsten Physik-Frame. In welchen Frames schreibt die Figur die ersten drei Zeilen `Druck attack`? Antworte in der Form `1, 2, 3`. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + if not mash: + return + if Input.is_action_pressed(&"attack"): + Input.action_release(&"attack") + mash_frame += 1 + if mash_frame >= mash_every: + mash_frame = 0 + Input.action_press(&"attack") + expected: | + 7, 13, 19 + accept: + - 7 13 19 + - 7,13,19 + explanation: | + Im ersten Physik-Frame steht `mash_frame` nach dem Hochzählen auf 1, im sechsten auf 6. Dann drückt der Bot und setzt den Zähler zurück. Die Figur sieht den Druck in Frame 7, danach alle 6 Frames: 13, 19. Mit `mash_every = 4` sind es 5, 9, 13 wie in der Lektion. Beide Fälle mit Godot 4.7.2 gemessen. + resources: + - title: Godot Docs · Input.action_press + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-action-press + + - id: naechster-treffer + type: output + title: Wann trifft der Bot wieder? + prompt: | + Mash-Bot gegen den Dummy. Der Spielerangriff hat die Platzhalter Wind-up 8, aktiv 3, Erholung 14, Hitstop 4, der Puffer ist 6. Der Bot sorgt dafür, dass immer ein frischer Druck im Puffer liegt, und der Dummy steht in Reichweite. In welchem Frame steht die nächste Zeile `Dummy getroffen`? Antworte mit der Zahl. + lang: text + code: | + 13 Spieler WINDUP -> ACTIVE + 14 Dummy getroffen: Zone body, Schaden 1.0, in TELEGRAPH Frame 4 + ??? Dummy getroffen: Zone body, Schaden 1.0, in TELEGRAPH Frame 4 + expected: | + 43 + accept: + - Frame 43 + hints: + - Der Hitstop hält die aktive Phase der Figur 4 Frames an. + - Der neue Wind-up beginnt im selben Frame, in dem die Erholung endet. + explanation: | + Die aktive Phase beginnt in Frame 13, der Treffer kommt in 14. Die Frames 14 bis 17 stehen still, 18 und 19 sind die aktiven Frames 2 und 3. Die Erholung belegt 20 bis 33, in Frame 34 wechselt die Figur nach `MOVE` und sofort nach `WINDUP`. Wind-up 8 endet mit Frame 41, die Hitbox ist ab 42 an, der Treffer kommt in 43. Das sind 29 Frames: 25 für den Angriff plus 4 Hitstop. Mit Godot 4.7.2 gemessen. + + - id: mash-bot-ergaenzen + type: gap + title: Drücken und loslassen + prompt: | + Ergänze den Mash-Bot: Er lässt eine gedrückte Angriffstaste im nächsten Frame los, zählt mit und drückt alle `mash_every` Frames neu. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + if not mash: + return + if Input.is_action_pressed(&"attack"): + ⟦Input.action_release⟧(&"attack") + mash_frame += 1 + if mash_frame >= mash_every: + mash_frame = ⟦0⟧ + ⟦Input.action_press⟧(&"attack") + gaps: + - label: Loslassen + hint: Das Gegenstück zum simulierten Druck, eine Methode von `Input`. + - label: Zähler zurücksetzen + hint: Ab hier zählt der Bot wieder von vorn. + - label: Drücken + hint: Simuliert einen Druck, den `is_action_just_pressed()` sieht. + explanation: | + `is_action_just_pressed()` ist nur in dem Frame wahr, in dem die Taste runtergeht. Deshalb muss der Bot loslassen, bevor er wieder drückt; sonst zählt nur der erste Druck. Der Zähler läuft bis `mash_every` und beginnt dann bei 0, so liegt zwischen zwei Drücken immer derselbe Abstand. + resources: + - title: Godot Docs · Input.action_release + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-action-release + + - id: movie-maker-befehl + type: gap + title: Ein Lauf als PNG-Folge + prompt: | + Ergänze den Befehl: Godot soll den Lauf als PNG-Folge nach `/tmp/lauf_b/` schreiben, mit festen 60 Bildern pro Sekunde, und nach 240 Bildern beenden. + lang: text + code: | + mkdir -p /tmp/lauf_b + godot --path . --debug-collisions ⟦--write-movie⟧ /tmp/lauf_b/bild.png ⟦--fixed-fps⟧ 60 ⟦--quit-after⟧ 240 + gaps: + - label: Movie Maker einschalten + hint: Die Option nimmt den Pfad der Ausgabedatei. + - label: Feste Bildrate + hint: Diese Option schaltet die Synchronisation mit der Echtzeit ab. + - label: Beenden + hint: Zählt Durchläufe der Hauptschleife. + explanation: | + `--write-movie` schaltet den Movie Maker ein, die Endung `.png` wählt die PNG-Folge. Die Bilder heißen `bild00000000.png`, `bild00000001.png` und so weiter. `--fixed-fps` erzwingt der Movie Maker ohnehin, die Option legt die Bildrate fest. `--quit-after 240` beendet nach 240 Durchläufen, das sind hier 240 Bilder. `mkdir -p` steht davor, weil Godot 4.7.2 den Ordner nicht selbst anlegt. + resources: + - title: Godot Docs · Creating movies + url: https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html + + - id: protokoll-zwei-groessen + type: bug + title: Der Telegraph war schuld? + prompt: | + In diesem Protokoll folgt die Entscheidung nicht aus der Messung. Finde die Zeile, die das verursacht, und korrigiere sie so, dass sie zur Hypothese passt. + lang: text + code: | + Datum: 16.10.2026 + Godot: 4.7.2-stable (official) + Hypothese: Der Dummy kommt gegen Dauerdrücken nie zum Schlag, + weil sein Telegraph länger dauert als die Pause + zwischen zwei Treffern. + Geänderte Größe: gegner_b.tres windup 20 -> 2, Dummy interruptible true -> false + Erwartung: Mash-Test: Der Dummy trifft den Bot mindestens einmal. + Messung: Mash-Bot, 240 Frames: 1 x Spieler getroffen (A: 0 x) + Entscheidung: B behalten. Der kurze Telegraph hat den Stun-Lock gebrochen. + lines: [6] + fix: + - line: 6 + answers: + - "Geänderte Größe: gegner_b.tres windup 20 -> 2" + - "Geänderte Größe: windup 20 -> 2" + - "Geänderte Größe: gegner_b.tres windup 20 -> 2, sonst nichts" + explanation: | + Die Runde ändert zwei Größen. Dann kann der Treffer gegen den Bot vom kurzen Telegraph kommen oder davon, dass der Dummy nicht mehr unterbrechbar ist. Mit Godot 4.7.2 nachgemessen: Jede der beiden Änderungen allein liefert im Mash-Test einen Treffer gegen den Bot. Die Entscheidung schreibt also einer Ursache zu, was zwei haben könnten. Weil die Hypothese vom Telegraph handelt, bleibt nur `windup` in dieser Runde; `interruptible` bekommt eine eigene. + resources: + - title: "Hades: The Big Bad Update (Patchnotes)" + url: https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/ + + - id: mash-takt-falsch + type: bug + title: Vier eingestellt, fünf gemessen + prompt: | + `mash_every` steht auf 4. Im Log stehen die Zeilen `Druck attack` aber in den Frames 6, 11, 16 – alle 5 Frames. Finde die Zeile und korrigiere sie. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + if not mash: + return + if Input.is_action_pressed(&"attack"): + Input.action_release(&"attack") + mash_frame += 1 + if mash_frame > mash_every: + mash_frame = 0 + Input.action_press(&"attack") + lines: [7] + fix: + - line: 7 + answers: + - "if mash_frame >= mash_every:" + - "if mash_every <= mash_frame:" + - "if mash_frame == mash_every:" + - "if mash_every == mash_frame:" + explanation: | + Mit `>` drückt der Bot erst, wenn `mash_frame` auf 5 steht, und setzt danach auf 0 zurück. Zwischen zwei Drücken liegen so `mash_every + 1` Frames. Mit `>=` drückt er bei 4. Mit Godot 4.7.2 nachgestellt: Mit `>` stehen die Drücke in 6, 11, 16, mit `>=` in 5, 9, 13. Ein Takt, der um eins daneben liegt, verfälscht jeden Mash-Test, ohne dass das Spiel kaputt aussieht. + resources: + - title: Godot Docs · Input.action_press + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-action-press + + - id: stillstand-beim-mashen + type: explain + title: Warum der Bot sich nicht bewegt + prompt: | + In einem Mash-Lauf hält ein Testskript zusätzlich `move_right` gedrückt. Trotzdem bleibt die Figur ab dem ersten Angriff auf x = 86,00 stehen, bis zum Ende des Laufs nach 240 Frames. Im Log steht `34 Spieler RECOVERY -> MOVE` und in derselben Frame-Nummer `34 Spieler MOVE -> WINDUP`. Erkläre am Code der Figur, warum sie keinen Schritt macht, und was das für den Mash-Test bedeutet. + lang: gdscript + code: | + func _physics_process(_delta: float) -> void: + _read_buttons() + if hitstop_left > 0: + hitstop_left -= 1 + return + # 1. Übergänge: Ist der Zustand abgelaufen, oder startet eine Aktion? + frame += 1 + if state != State.MOVE and frame > _duration(state): + _enter(_next_state(state)) + if state == State.MOVE: + if _consume(&"attack"): + _enter(State.WINDUP) + elif _consume(&"dodge"): + _enter(State.DODGE) + elif state == State.RECOVERY and attack.cancel_from > 0 and frame >= attack.cancel_from: + if _consume(&"attack"): + _enter(State.WINDUP) + _update_hurtbox() + # 2. Verhalten im (neuen) Zustand + match state: + State.MOVE: + _move() + points: + - Der Bot drückt alle 4 Frames, der Puffer hält einen Druck 6 Frames; am Ende jeder Erholung liegt also ein gültiger Druck bereit. + - Im Frame, in dem die Erholung endet, laufen erst die Übergänge; `_consume` findet den Druck, und die Figur wechselt noch im selben Frame nach `WINDUP`. + - "`_move()` läuft nur, wenn die Figur nach den Übergängen in `MOVE` ist; das ist nie der Fall, also macht sie keinen Schritt." + - Der Mash-Test misst einen stehenden Angreifer; ein Mensch, der hämmert und dabei läuft, erzeugt andere Lagen, deshalb ersetzt der Bot den Test von Hand nicht. + explanation: | + Die Figur entscheidet in jedem Frame zuerst über Übergänge und handelt erst danach. Endet die Erholung, wechselt sie nach `MOVE`, und im selben Frame startet der gepufferte Angriff. Weil der Bot schneller drückt, als der Puffer verfällt, ist das bei jedem Ende einer Erholung so. `_move()` kommt nie dran, gedrückte Richtung hin oder her. Das ist kein Fehler im Bot, sondern eine Eigenschaft des Tests: Er stellt einen Spieler nach, der stehen bleibt und drückt. Was ein Mensch dabei anders macht, siehst du nur von Hand oder bei einer anderen Person. + resources: + - title: Godot Docs · Input.is_action_just_pressed + url: https://docs.godotengine.org/en/stable/classes/class_input.html#class-input-method-is-action-just-pressed + + - id: tuning-runde + type: practice + title: Eine Tuning-Runde mit Protokoll + prompt: | + Bau den Versuch-Node in deine Testszene ein. Wähle selbst eine Größe des Trainingsgegners, die du ändern willst. Schreib die Hypothese mit Ursache und erwarteter Wirkung ins Protokoll, bevor du `gegner_b.tres` anlegst. Teste A und B jeweils mit dem Mash-Bot und von Hand und füll das Protokoll vollständig aus. + lang: text + solution: | + Beispiel für die Form, nicht für den Wert (Platzhalter, + Trainingsgegner, andere Größe als in der Lektion): + + Datum ‹Datum› + Godot 4.7.2-stable (official) + Ausgangslage Spieler angriff_kb0.tres, + Dummy gegner_a.tres, wait 10 + Hypothese Mit längerer Pause vor jedem Angriff + trifft mich der Dummy seltener, weil + ich nach jedem Ausweichen länger frei bin. + Größe alt -> neu wait_frames 10 -> 30, sonst nichts + Erwartung ‹vorher aufschreiben› + Messung (Log) ‹Zeilen aus A und B› + Eindruck ‹deine Worte› + Entscheidung ‹deine› + Offene Frage ‹deine› + checklist: + - Die Hypothese mit Ursache und erwarteter Wirkung stand im Protokoll, bevor du `gegner_b.tres` angelegt hast. + - "`gegner_a.tres` ist unverändert, und `diff` zeigt in den Kopfzeilen von A und B genau eine abweichende Zeile mit einem abweichenden Wert." + - Datum und Godot-Version stehen im Protokoll, die Version aus der Kopfzeile des Logs. + - Mash-Test und Reaktionstest sind getrennt notiert, jeweils für A und B, mit Zahlen aus dem Log. + - Der Eindruck steht in deinen Worten und beschreibt, was du bemerkt hast, nicht, ob der Wert „richtig“ ist. + - Entscheidung und offene Frage hast du selbst formuliert; die offene Frage nennt wieder genau eine Größe oder einen Test. + explanation: | + Die Checkliste prüft die Form, nicht deinen Wert. Welche Größe du wählst und ob du B behältst, ist deine Entscheidung. Wichtig ist, dass jemand anderes, oder du in drei Monaten, aus dem Protokoll nachvollziehen kann, was du erwartet, geändert und gemessen hast. Die Kopfzeilen machen das prüfbar, statt dass du dich an Werte erinnern musst. + resources: + - title: Valve's Approach to Playtesting (PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: mash-gegen-reaktion + type: practice + title: Dauerdrücken gegen Lesen + prompt: | + Nimm deine aktuelle Variante des Trainingsgegners. Lass den Mash-Bot zweimal hintereinander 240 Frames laufen und vergleiche die Logs. Mach dann den Reaktionstest von Hand mit mindestens zehn gegnerischen Angriffen. Vollzieh einen Treffer gegen dich Bild für Bild nach. Notiere, ob gegen diese Variante Dauerdrücken oder Lesen besser abschneidet. + lang: text + solution: | + Beispiel für die Form: + + $ godot --headless --path . --fixed-fps 60 --quit-after 240 > mash_1.log + $ godot --headless --path . --fixed-fps 60 --quit-after 240 > mash_2.log + $ diff mash_1.log mash_2.log (keine Ausgabe: gleich) + $ grep -c "Spieler getroffen" mash_1.log + $ godot --path . > hand.log (mash aus, von Hand) + $ grep -c "TELEGRAPH -> ACTIVE" hand.log + $ grep -c "Spieler getroffen" hand.log + + Notiz: Mash: ‹Treffer gegen den Bot›, Hand: ‹Treffer› bei ‹Angriffe›. + Treffer in Frame ‹F› nachvollzogen in bild‹F − 1›.png: + ‹was du gesehen hast› + checklist: + - Der Versuch-Node schreibt Version und Werte als erste Zeilen ins Log. + - Zwei Mash-Läufe mit denselben Werten ergeben dasselbe Log, `diff` zeigt nichts. + - Der Reaktionstest umfasst mindestens zehn gegnerische Angriffe, gezählt mit `grep -c`. + - Du hast einen Treffer gegen dich mit F9/F10 oder in der PNG-Folge des Movie Makers gefunden und Zustand und Frame aus dem Bild mit dem Log abgeglichen. + - Du hast notiert, ob Dauerdrücken oder Lesen besser abschneidet, mit Zahlen aus beiden Tests, und ob dich das stört. Das ist deine Bewertung. + explanation: | + Der doppelte Mash-Lauf prüft das Werkzeug: Sind zwei Läufe gleich, kannst du Unterschiede zwischen A und B den Werten zuschreiben. Der Reaktionstest zeigt die andere Seite. Gewinnt Dauerdrücken, lohnt sich Lesen nicht; verliert Lesen gegen einen sehr kurzen Telegraph, bestraft das Muster auch aufmerksame Spieler. Welches Verhältnis du willst, legst du fest. Der Abgleich zwischen Bild und Log übt den Weg, den du bei jedem unklaren Treffer gehst. + resources: + - title: Godot Docs · Creating movies + url: https://docs.godotengine.org/en/stable/tutorials/animation/creating_movies.html + - title: Godot Docs · Game embedding (Pause, Frame advance) + url: https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10 + + - id: fremdtest + type: practice + title: Jemand anderes spielt + prompt: | + Lass eine andere Person gegen deinen Trainingsgegner spielen, mindestens zehn gegnerische Angriffe lang. Erkläre nur die Tasten. Beobachte, was sie tut, und speichere das Log. Frag erst danach. Vergleiche das Ergebnis mit deinem eigenen Reaktionstest. + lang: text + solution: | + Beispiel für die Form: + + Datum, Godot-Version, Variante: aus der Kopfzeile von fremd.log + Erklärt: nur Tasten (Bewegen, attack, dodge) + Beobachtet (was sie tut): + Anlauf 1: ‹…› + Anlauf 2: ‹…› + Log: ‹Treffer gegen sie› bei ‹gegnerische Angriffe› + Gesagt (danach, getrennt notiert): ‹…› + Unterschied zu meinem Test: ‹…› + Meine Entscheidung danach: ‹…› + checklist: + - Vor dem Spiel hast du nur die Tasten erklärt, nichts über Muster, Telegraph oder Werte. + - Das Log des Fremdtests ist gespeichert, mit Version und Werten in den Kopfzeilen. + - Du hast notiert, was die Person getan hat, etwa dauernd gedrückt, gewartet oder wohin ausgewichen, getrennt davon, was sie gesagt hat. + - Fragen hast du erst nach dem Spiel gestellt, offen formuliert („Was ist da passiert?“ statt „War der Telegraph zu kurz?“). + - Du hast mindestens eine Beobachtung festgehalten, die dir beim eigenen Test nicht aufgefallen ist, oder notiert, dass es keine gab. + - Ob du danach etwas änderst, hast du selbst entschieden und als neue Hypothese formuliert. + explanation: | + Eine andere Person kennt dein Muster nicht. Was sie tut, zeigt, ob der Telegraph ohne Vorwissen lesbar ist. Valve hält fest, dass zählt, was Leute tun, nicht was sie sagen, und dass Beobachter das Ergebnis verzerren können. Deshalb sitzt du still daneben und fragst erst am Ende. Ihre Erklärung ist eine Information, keine Anweisung: Was du daraus machst, entscheidest du. + resources: + - title: Playtesting – Avoiding Evil Data (GDC 2017) + url: https://www.youtube.com/watch?v=6EUeYu0aPn4 + - title: Never Skip Playtest Day! (Coin Crew, GDC 2023) + url: https://gdcvault.com/play/1028825/Independent-Games-Summit-Never-Skip diff --git a/content/combat/modules/vorgaben.yaml b/content/combat/modules/vorgaben.yaml new file mode 100644 index 0000000..c804ff5 --- /dev/null +++ b/content/combat/modules/vorgaben.yaml @@ -0,0 +1,533 @@ +id: vorgaben +title: Kampfmechanik als Vorgabe für einen Coding-Agenten +level: 2 +minutes: 90 +summary: Wie du eine Kampfmechanik so aufschreibst, dass ein Coding-Agent nur Code schreibt und keine Designentscheidung trifft – mit Zuständen, Parametern in Frames, Konfliktregeln, Log-Zeilen und Abnahmekriterien, die du am Log prüfst. +goals: + - Du erkennst Stellen in einer Vorgabe, an denen ein Coding-Agent eine Designentscheidung treffen müsste. + - Du schreibst eine Vorgabe mit Ziel, Zuständen, Parametern in Frames, Konfliktregeln, Log-Zeilen, Abnahme, offenen Fragen und dem, was der Agent nicht entscheidet. + - Du rechnest aus Vorgabe und Platzhalterwerten vorher aus, in welchem Frame eine Log-Zeile steht. + - Du trennst die Abnahme (tut der Code, was dasteht?) vom Experiment (stimmt deine Hypothese?). + - Du ordnest eine Abweichung im Log dem Code, der Vorgabe oder deiner Vorhersage zu. +resources: + - title: Stone Librande · One-Page Designs (GDC 2010) + url: https://gdcvault.com/play/1012356/One-Page + - title: Ian Schreiber · The Four One-Page Design Docs You Need (GDC 2025) + url: https://gdcvault.com/play/1035120/The-Four-One-Page-Design + - title: Mike Ambinder · Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + - title: Godot Docs · GDScript exported properties + url: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html +lesson: | + > [!NOTE] Unterwegs + > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. + + Du schreibst dem Coding-Agenten: „Der Trainingsgegner soll vor dem Angriff kurz aufleuchten und dann hart zuschlagen.“ Zehn Minuten später läuft die Szene. Der Gegner leuchtet 10 Frames, der Treffer hat 8 Frames Hitstop und 300 px/s Knockback (ausgedachte Zahlen für dieses Beispiel). Keine davon hast du gewählt. Der Agent hat sie eingesetzt, weil der Satz keine enthielt. Ob „kurz“ jetzt umgesetzt ist, kannst du nicht prüfen: Es gibt nichts, womit du das Ergebnis vergleichen könntest. + + Diese Lektion zeigt, wie du eine Kampfmechanik so aufschreibst, dass der Agent nur noch Code schreibt. Die Entscheidungen bleiben bei dir, und das Ergebnis prüfst du am Log. Das Beispiel ist eine neutrale Änderung an der Testszene aus [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff), kein Entwurf für dein Spiel. + + ## Was ein Agent mit Lücken macht + + Ein Coding-Agent soll liefern. Fehlt eine Angabe, füllt er die Lücke mit einer Annahme, die plausibel aussieht. Aus „kurz“ wird eine Zahl, aus „danach“ eine Reihenfolge, aus „hart“ ein Hitstop und ein Knockback. Daraus folgen drei Probleme: + + 1. Die Annahme ist eine Designentscheidung, die du nicht getroffen hast. + 2. Du siehst sie nicht. Im Code steht `windup := 10` genauso selbstverständlich wie `windup := 8`. + 3. Du kannst nicht prüfen, ob das Ergebnis stimmt, weil „kurz“ kein Maßstab ist. + + Die Grenze zieht die Regel dieses Kurses: Den Code darf der Agent schreiben, die Werte sind deine Sache. Er legt jede Größe, die der Spieler spürt, als einstellbaren Parameter an, mit `@export` oder `@export_range`, und markiert den Startwert ausdrücklich als Platzhalter ([GDScript exported properties](https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html#limiting-editor-input-ranges)): + + ```gdscript + ## Telegraph-Frame, ab dem Stufe 2 gilt. + ## 0 = keine Stufen, Fläche voll wie bisher. + ## PLATZHALTER – der Wert wird noch festgelegt. + @export_range(0, 60, 1, "suffix:Frames") + var stage2_from := 13 + ``` + + `@export_range` begrenzt den Regler im Inspektor, der Hinweis `"suffix:…"` schreibt die Einheit daneben ([@export_range](https://docs.godotengine.org/en/stable/classes/class_@gdscript.html#class-gdscript-annotation-export-range)). Der Bereich ist keine Empfehlung, er fängt Tippfehler ab. Der Kommentar sagt jedem, der die Datei öffnet, auch dem nächsten Agenten: Diese 13 hat niemand entschieden. + + Technische Entscheidungen darf der Agent treffen: wie eine Variable heißt, in welcher Datei ein Feld liegt, ob er eine Fläche über `modulate` blasser macht oder eine zweite Fläche anlegt. Alles, was der Spieler spürt – Dauer, Reihenfolge, Reichweite, was was abbricht –, steht in der Vorgabe oder ist ein markierter Platzhalter. + + > [!MERKE] + > Der Agent baut den Regler. Du stellst ihn ein. + + ## Aufbau: die acht Teile einer Vorgabe + + Eine Vorgabe beantwortet die Fragen, die der Agent sonst selbst beantworten würde. Die Reihenfolge folgt den Abhängigkeiten: Parameter gelten in Zuständen, Konfliktregeln brauchen beides, und Abnahmekriterien brauchen die Log-Zeilen, an denen du sie prüfst. + + | Teil | Frage | + | --- | --- | + | 1. Ziel | Was willst du testen? | + | 2. Zustände | Welche, und wann wechseln sie? | + | 3. Parameter | Name, Einheit, Bereich, Platzhalter? | + | 4. Konflikte | Was bricht was ab? | + | 5. Debug | Welche Log-Zeilen? | + | 6. Abnahme | Welches Log beweist den Bau? | + | 7. Offen | Was entscheidest du später? | + | 8. Nicht entscheiden | Was fasst der Agent nicht an? | + + **Ziel in eigenen Worten.** Ein, zwei Sätze, was der Spieler erleben soll oder was du ausprobieren willst. Der Agent kennt das Ziel, damit er Widersprüche meldet. Optimieren soll er es nicht. + + **Zustände und Übergänge.** Nenne jeden Zustand so, wie er im Log heißt, und die Bedingung für jeden Wechsel: `WAIT -> TELEGRAPH -> ACTIVE -> RECOVERY -> WAIT`. Ein Übergang ohne Bedingung ist eine Lücke. + + **Parameter mit Einheit.** Jede Dauer in Frames bei 60 Physik-Frames pro Sekunde, jede Geschwindigkeit in px/s, jede Strecke in px. Ein Frame ist ein Aufruf von `_physics_process` ([Idle and Physics Processing](https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html)). Sag, wie du zählst: Frames eines Zustands ab 1, wie in der Testszene. Zu jedem Parameter gehören Bereich und Platzhalter. + + **Konfliktregeln.** Hier entstehen die meisten Lücken, deshalb folgt unten ein eigener Abschnitt. + + **Debug-Anforderungen.** Jede neue Mechanik schreibt ihre Ereignisse über `HitLog.add()` ins Log. So bekommt jede Zeile die Physik-Frame-Nummer. Ohne Log-Zeile kannst du nichts prüfen. + + **Abnahmekriterien.** Sätze der Form „Zeile X steht in Frame T + n“, gerechnet mit den Platzhaltern. Der Agent kann sie selbst prüfen, du auch. + + **Offene Fragen.** Was du noch nicht weißt, schreibst du hin. Dann füllt der Agent die Stelle nicht. + + **Nicht entscheiden.** Eine kurze Liste: keine Werte außer den genannten ändern, keine Animation, kein Sound, bei Unklarheit zurückfragen statt raten. + + ### Konfliktregeln: wenn zwei Dinge zusammenkommen + + Ein Angriff allein ist leicht beschrieben. Schwierig wird es, wenn er auf etwas anderes trifft. Geh bei jeder Vorgabe diese Fragen durch: + + - **Abbruch:** Was bricht die neue Aktion ab, und was darf sie abbrechen? Ein Treffer im Wind-up, ein Ausweichen in der Erholung? + - **Puffer:** Gilt der Eingabepuffer auch für die neue Aktion? Was passiert mit einem Druck, der während der Aktion kommt? + - **Hitstop:** Zählen die Frames der neuen Aktion im Hitstop weiter, oder stehen sie still? + - **Gleichzeitig:** Treffen sich beide Seiten im selben Frame, zählen beide Treffer, oder gewinnt einer? + + Fehlt eine Regel, entscheidet die Reihenfolge, in der der Agent die Fälle im Code prüft. Das ist auch eine Designentscheidung, nur eine, die keiner bemerkt. Passt eine Frage nicht zu deiner Änderung, schreib das hin: „Gleichzeitiger Treffer: entfällt, weil …“. Dann sieht der Agent, dass du daran gedacht hast. + + > [!TIP] Erst zurückfragen lassen + > Bitte den Agenten, die Vorgabe vor dem Code in eigenen Worten zusammenzufassen und alle Fragen zu stellen. Jede Frage zeigt eine Lücke. Die Antwort schreibst du in die Vorgabe, nicht nur in den Chat, sonst fehlt sie beim nächsten Umbau. Leg die Vorgabe als Datei neben den Code, dann versioniert Git sie mit. + + ## One-Page Designs: eine Seite, eine Hypothese + + Kurze Designdokumente sind keine Erfindung für KI-Agenten. Stone Librande stellte 2010 auf der GDC **One-Page Designs** vor: Designdokumente, die auf eine Seite passen, mit Beispielen aus Diablo III, The Simpsons und Spore ([One-Page Designs, GDC Vault](https://gdcvault.com/play/1012356/One-Page)). Eine Fassung aus einer Seminarreihe der Stanford University gibt es frei als [Video](https://www.youtube.com/watch?v=GXmsxYm0Mk0); achte darauf, welche Fragen eine Seite beantwortet und welche sie offenlässt. Für SimCity entstanden laut der Beschreibung seines Vortrags von 2013 über 100 solcher Dokumente ([Simulating a City, One Page at a Time](https://gdcvault.com/play/1017708/Simulating-a-City-One-Page)). Ian Schreiber stellte auf der GDC 2025 vier One-Page-Dokumente vor: Vision, Pillars (Grundpfeiler), Loops (Spielschleifen) und Ressourcenfluss ([The Four One-Page Design Docs You Need](https://gdcvault.com/play/1035120/The-Four-One-Page-Design)). + + Schreibers vier Dokumente beschreiben ein ganzes Spiel, deine Vorgabe eine einzelne Mechanik. Die Idee ist dieselbe: eine Seite, die das Team ohne Rückfrage lesen kann. Tim Ryan schrieb schon 1999, Designdokumente seien für Programmierer „instructions for implementation“ und sollten „eliminate the vagueness“ ([The Anatomy of a Design Document](https://www.gamedeveloper.com/design/the-anatomy-of-a-design-document-part-1-documentation-guidelines-for-the-game-concept-and-proposal)). Ein Agent ist ein Programmierer, der jede Unschärfe auf seine Weise auflöst. + + Für die Abnahme hilft Valves Sicht auf Playtests. Mike Ambinder fasste den Designprozess 2009 so zusammen: „Game designs are hypotheses / Playtests are experiments / Evaluate designs off playtest results / Repeat“ ([Valve's Approach to Playtesting, PDF](https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf)). Auf deine Vorgabe übertragen gibt es zwei Prüfungen: + + | Prüfung | Frage | Wer prüft | + | --- | --- | --- | + | Abnahme | Tut der Code, was dasteht? | Log, Agent, du | + | Experiment | Stimmt die Hypothese? | nur du, beim Spielen | + + Die Abnahme muss bestehen, bevor das Experiment etwas bedeutet. Sonst beurteilst du einen Fehler statt eines Werts. Ein Experiment vergleicht laut Valve „two or more conditions“, also zwei Einstellungen derselben Mechanik. Wie du das aufbaust, steht in [Tuning als Experiment](/combat/tuning). + + > [!PRAXIS] Code fest, Daten beweglich + > Greg Kasavin beschrieb für Hades monatliche Meilensteine bei Supergiant: Am Anfang sind größere Codeänderungen möglich, „and then we lock the code down and we can still make data changes, and fine tune things“ ([Game Developer](https://www.gamedeveloper.com/design/supergiant-s-fourth-outing-i-hades-i-introduces-a-more-mature-organized-dev-process)). In dieser Phase stimmte das Team also Daten ab, nicht Code. In den Patchnotes zum „Big Bad Update“ steht etwa für Thanatos „increased preattack duration and recovery time“ ([Supergiant](https://www.supergiantgames.com/blog/hades-big-bad-update-patch-notes/)). Für deine Vorgaben heißt das: Jeder Wert, den du später abstimmen willst, gehört in einen Parameter. + + ## Beispiel: Telegraph in zwei Stufen + + Die Änderung betrifft nur die Testszene. Der Trainingsgegner kündigt seinen Angriff mit einer gelben Bodenfläche an, die während des ganzen Telegraphs gleich aussieht. Die Vorgabe macht daraus zwei Stufen. Ob zwei Stufen besser lesbar sind, ist die Hypothese; die Vorgabe sorgt nur dafür, dass du sie testen kannst. Der Node des Gegners heißt hier `Gegner`, alle Werte sind willkürliche Platzhalter. + + ```text + VORGABE: Telegraph in zwei Stufen + Ort: Testszene, Trainingsgegner + + 1 ZIEL + Ich will testen, ob ich den Angriff + früher erkenne, wenn die gelbe Fläche + erst blass und dann voll erscheint. + + 2 ZUSTÄNDE + WAIT -> TELEGRAPH -> ACTIVE + -> RECOVERY -> WAIT bleibt. + Frames eines Zustands zählen ab 1. + TELEGRAPH hat zwei Stufen: + Stufe 1: Frame 1 bis stage2_from - 1 + Stufe 2: Frame stage2_from bis windup + + 3 PARAMETER (je Angriff, @export_range, + jeder Startwert als PLATZHALTER markiert) + stage2_from Telegraph-Frame, 0 bis 60 + Platzhalter 13 + 0 = keine Stufen, Fläche voll + stage1_alpha Deckkraft Stufe 1, 0 bis 1 + Platzhalter 0.3 + windup bleibt, Platzhalter 20 + Stufe 2 zeigt die bisherige Fläche. + + 4 KONFLIKTE + Treffer im TELEGRAPH, interruptible: + wie bisher HITSTUN, Fläche aus. + Treffer, nicht interruptible: + Hitstop hält den Telegraph an, + Stufe 2 verschiebt sich mit. + stage2_from > windup: Stufe 2 kommt + nie, Warnung beim Start ins Log. + Gleichzeitiger Treffer: entfällt, + im TELEGRAPH ist die Hitbox aus. + + 5 DEBUG + Neue Zeile über HitLog.add(): + " TELEGRAPH Stufe 2" + im ersten Frame von Stufe 2. + + 6 ABNAHME (Platzhalter, kein Treffer) + WAIT -> TELEGRAPH Frame T + TELEGRAPH Stufe 2 Frame T + 12 + TELEGRAPH -> ACTIVE Frame T + 20 + stage2_from = 0: keine Stufe-2-Zeile, + übriges Log wie vorher. + + 7 OFFEN + Farbe von Stufe 2, Ton dazu: + entscheide ich nach dem ersten Test. + + 8 NICHT ENTSCHEIDEN + Keine anderen Werte ändern. + Keine Animation, kein Sound. + Unklares zurückfragen, nicht raten. + ``` + + Was an dieser Vorgabe die Arbeit macht: + + - **Die Abnahme folgt aus den Platzhaltern.** Stufe 2 beginnt im 13. Telegraph-Frame, also 12 Frames nach dem ersten. Zählt der Agent anders, fällt es in der zweiten Zeile auf. + - **`stage2_from = 0` hält das alte Verhalten bereit.** Für ein Experiment brauchst du beide Bedingungen in derselben Szene. + - **Die Konfliktregeln nennen auch den Fall, der nicht vorkommen kann.** Der Agent muss nicht raten, ob du ihn vergessen hast. + - **Abschnitt 7 markiert, wo der Agent nicht weiterdenken soll.** Sonst bekommt Stufe 2 eine Farbe, die er schön findet. + - **Wo `stage2_from` im Code liegt, steht nicht da.** Das ist eine technische Frage, die darf der Agent beantworten. „Je Angriff“ dagegen ist Design und steht deshalb drin. + + ## Das Ergebnis prüfen: vorhersagen, starten, vergleichen + + Wie in [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) gilt: erst vorhersagen, dann messen. Bevor du die Szene startest, schreibst du das erwartete Log auf. Beginnt der Telegraph in Frame 100: + + ```text + 100 Gegner WAIT -> TELEGRAPH + 112 Gegner TELEGRAPH Stufe 2 + 120 Gegner TELEGRAPH -> ACTIVE + ``` + + Steht die Spielfigur in der Fläche, folgt in Frame 121 ihre Treffer-Zeile: Die Hitbox geht in Frame 120 an, und Godot meldet die Überlappung einen Frame später (mit Godot 4.7.2 gemessen). + + Dann startest du die Szene und vergleichst Zeile für Zeile. Willst du die Fläche Frame für Frame sehen, hilft der Einzelbild-Schritt mit F10 im Game-Fenster ([Frame advance](https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10)). Jede Abweichung hat eine von drei Ursachen: + + | Abweichung | Ursache | Was du tust | + | --- | --- | --- | + | Log weicht von der Vorgabe ab | Code | Agent korrigiert | + | Vorgabe erlaubt zwei Lesarten | Vorgabe | präzisieren, neu bauen | + | Log passt, Rechnung nicht | Vorhersage | Denkmodell prüfen | + + Ein Beispiel: Angenommen, das Log zeigt `TELEGRAPH Stufe 2` in Frame 113 statt 112. Dann prüft der Code vermutlich `frame > stage2_from` statt `frame >= stage2_from`. Die Vorgabe sagt „Stufe 2: Frame stage2_from bis windup“ und „Frames zählen ab 1“, also liegt der Fehler im Code. Stünde dort nur „Stufe 2 ab stage2_from“, wäre es eine Lücke in der Vorgabe gewesen, und beide Lesarten wären vertretbar. + + Erst wenn das Log passt, spielst du. Ab da ist das Gefühl deine Sache: Erkennst du den Angriff früher als mit einer Stufe? Das notierst du in deinem Testprotokoll, nicht in der Abnahme. + + > [!INTERVIEW] + > „Beschreib einen Gegnerangriff so, dass ihn ein Programmierer ohne Rückfrage bauen kann.“ So eine Aufgabe kann in einem Designtest kommen. Gute Antwort: Zustände und Übergänge nennen, jede Dauer in Frames mit Bildrate und jede Strecke in Pixeln. Dann die Konflikte: was den Angriff unterbricht, was im Hitstop passiert, was bei gleichzeitigem Treffer gilt. Zum Schluss, woran man erkennt, dass er fertig ist, etwa an Log-Zeilen mit Frame-Nummer, und welche Werte noch Platzhalter sind. Wer nur das Gefühl beschreibt („schnell, wuchtig“), überlässt das Design dem Programmierer. + + ## Häufige Fehler + + - **Sekunden und Frames gemischt.** „Telegraph 20 Frames, Hitstop 0,05 s“: Der Agent rechnet um oder nicht, und im Code sieht beides gleich aus. Schreib alles in Frames und rechne vorher um (ms = Frames × 1000 / 60). Läuft etwas in Sekunden, etwa ein Tween für das Aufblitzen, schreib die Einheit ausdrücklich dazu. + - **„Kurz“ und „schnell“ statt Zahlen.** Jedes Adjektiv wird zu einer Zahl, die der Agent wählt. Kennst du die Zahl noch nicht, schreib „Platzhalter“ und einen Bereich. + - **Abnahme „fühlt sich gut an“.** Das kann kein Agent prüfen. Versucht er es, ändert er Werte nach eigenem Geschmack. Das Gefühl beurteilst du selbst im Experiment; in die Abnahme gehören Log-Zeilen und Frames. + - **Werte festgelegt, die erst ein Experiment klären soll.** „stage2_from = 13“ ohne Zusatz, obwohl du den Wert erst finden willst. Dann landet die 13 als Konstante im Code, und jeder Versuch ist eine Codeänderung. Schreib „Platzhalter 13, Bereich 0 bis 60“. + - **Fehlende Konfliktregeln.** Treffer, Hitstop, Puffer, gleichzeitiger Treffer: Ohne Regel entscheidet die Reihenfolge im Code. + - **Abnahme ohne Frame.** „Stufe 2 erscheint“ prüft nur, dass es sie gibt, nicht wann. Schreib „in Frame T + 12“. + + ## Zusammenfassung + + - Ein Agent füllt jede Lücke mit einer Annahme. Was du nicht aufschreibst, entscheidet er. + - Werte sind deine Sache. Der Agent legt sie als `@export`-Parameter mit markiertem Platzhalter an. + - Eine Vorgabe hat acht Teile: Ziel, Zustände, Parameter, Konflikte, Debug, Abnahme, offene Fragen, nicht entscheiden. + - Dauern stehen in Frames bei 60 pro Sekunde, gezählt ab 1; Geschwindigkeiten in px/s. + - Konfliktregeln klären Abbruch, Puffer, Hitstop und gleichzeitige Treffer, auch die Fälle, die entfallen. + - Die Abnahme prüft am Log, ob der Code tut, was dasteht. Das Experiment prüft deine Hypothese, und das Gefühl beurteilst nur du. + - Vor dem Start das Log vorhersagen, danach vergleichen und jede Abweichung Code, Vorgabe oder Vorhersage zuordnen. +exercises: + - id: luecken-erkennen + type: choice + title: Wo muss der Agent raten? + prompt: | + Welche Zeilen aus dieser Vorgabe zwingen den Agenten, eine Designentscheidung zu treffen? + options: + - text: Der Gegner holt kurz aus. + correct: true + why: „Kurz“ hat keine Zahl. Der Agent wählt eine, und du siehst im Code nicht, dass es seine war. + - text: "`windup`: Frames, Bereich 1 bis 60, Platzhalter 20." + correct: false + why: Einheit, Bereich und Platzhalter stehen da. Der Agent baut den Regler, den Wert stellst du ein. + - text: "Treffer im Telegraph: bei `interruptible` wie bisher HITSTUN, sonst hält der Hitstop den Telegraph an." + correct: false + why: Die Zeile nennt beide Fälle und verweist auf geprüftes Verhalten der Testszene. Da gibt es nichts zu raten. + - text: Der Schlag soll sich wuchtig anfühlen. + correct: true + why: Wucht kann Hitstop, Knockback, Screenshake oder Sound sein. Der Agent wählt Mittel und Werte. + explanation: | + Eine Designentscheidung steckt überall dort, wo ein Wort für eine Zahl oder eine Regel steht. „Kurz“ und „wuchtig“ lassen dem Agenten die Wahl, und er trifft sie, ohne nachzufragen. Eine Zeile mit Einheit, Bereich und Platzhalter legt dagegen nur fest, was gebaut wird. Den Wert stellst du später selbst ein. + resources: + - title: Godot Docs · Limiting editor input ranges + url: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html#limiting-editor-input-ranges + + - id: abnahme-pruefbar + type: choice + title: Was kann der Agent abnehmen? + prompt: | + Welche Abnahmekriterien für den Telegraph in zwei Stufen kann der Agent am Log prüfen, ohne selbst eine Designentscheidung zu treffen? + options: + - text: "`Gegner TELEGRAPH Stufe 2` steht im Log in Frame T + 12." + correct: true + why: Eine Zeile, ein Frame. Das Log zeigt, ob es stimmt. + - text: "Mit `stage2_from = 0` ist das Log Zeile für Zeile wie vor der Änderung." + correct: true + why: Ein Vergleich zweier Logs, keine Bewertung. So prüfst du auch, dass das alte Verhalten erhalten bleibt. + - text: Der Angriff ist mit zwei Stufen fair lesbar. + correct: false + why: Das ist ein Urteil über das Spielgefühl. Es gehört in dein Experiment. Ein Agent, der es erfüllen soll, ändert Werte nach eigener Annahme. + - text: Stufe 2 erscheint rechtzeitig vor dem Treffer. + correct: false + why: „Rechtzeitig“ nennt keinen Frame. Der Agent müsste festlegen, was rechtzeitig ist. + explanation: | + Die Abnahme beantwortet nur eine Frage: Tut der Code, was in der Vorgabe steht? Dafür braucht es Log-Zeilen mit Frame-Nummer oder einen Vergleich mit einem bekannten Log. Ob der Angriff dadurch lesbarer wird, ist die Hypothese. Die prüfst du selbst beim Spielen, nachdem die Abnahme bestanden ist. + resources: + - title: Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: teile-einer-vorgabe + type: order + title: Die acht Teile + prompt: | + Bring die Teile einer Vorgabe in eine sinnvolle Reihenfolge. Jeder Teil baut auf den vorigen auf. + lang: text + lines: | + Ziel: was du testen willst + Zustände und Übergänge + Parameter mit Einheit und Platzhalter + Konfliktregeln + Debug: neue Log-Zeilen + Abnahme: Log-Zeile in Frame T + n + Offene Fragen + Nicht entscheiden + alternatives: + - [1, 2, 3, 5, 4, 6, 7, 8] + - [1, 2, 3, 4, 5, 6, 8, 7] + - [1, 2, 3, 5, 4, 6, 8, 7] + explanation: | + Das Ziel steht vorn, damit der Agent Widersprüche erkennt. Parameter gelten in Zuständen, also kommen die Zustände davor. Konfliktregeln brauchen beides, etwa „Hitstop hält den Telegraph an“. Die Abnahme verweist auf die Log-Zeilen aus dem Debug-Teil und muss deshalb danach stehen. Offene Fragen und „nicht entscheiden“ schließen ab; ihre Reihenfolge ist egal, ebenso die von Konflikten und Debug. + resources: + - title: Stone Librande · One-Page Designs (GDC 2010) + url: https://gdcvault.com/play/1012356/One-Page + + - id: ergebnis-pruefen + type: order + title: Der Agent meldet „fertig“ + prompt: | + Der Agent hat den Telegraph in zwei Stufen gebaut. Bring die Schritte in die Reihenfolge, in der du das Ergebnis prüfst. + lang: text + lines: | + Log aus Vorgabe und Platzhaltern vorhersagen + Szene mit den Platzhaltern starten + Log mit der Vorhersage vergleichen + Abweichung zuordnen: Code, Vorgabe, Rechnung + Korrigieren, bis das Log passt + Spielen und den Eindruck notieren + explanation: | + Die Vorhersage steht fest, bevor du das Log siehst. Sonst liest du ins Log hinein, was du erwartest. Erst der Vergleich zeigt eine Abweichung, und erst die Zuordnung sagt, wer sie behebt: der Agent den Code, du die Vorgabe oder dein Denkmodell. Gespielt wird zuletzt. Wer vorher spielt, beurteilt womöglich einen Fehler statt einer Mechanik. + resources: + - title: Godot Docs · Game embedding · Frame advance (F10) + url: https://docs.godotengine.org/en/stable/tutorials/editor/game_embedding.html#frame-advance-f10 + + - id: stufe-zwei-frame + type: output + title: Wann wird die Fläche voll? + prompt: | + Der Agent hat die Vorgabe aus der Lektion umgesetzt. Platzhalter: `windup = 20`, `stage2_from = 13`. In welchem Physik-Frame steht `Gegner TELEGRAPH Stufe 2`? Antworte mit der Zahl. + lang: text + code: | + 100 Gegner WAIT -> TELEGRAPH + ??? Gegner TELEGRAPH Stufe 2 + expected: | + 112 + accept: + - Frame 112 + explanation: | + Frame 100 ist der erste Telegraph-Frame. Stufe 2 beginnt laut Vorgabe im Telegraph-Frame `stage2_from`, also im 13. Der liegt zwölf Frames nach dem ersten: 100 + 13 − 1 = 112. Dieselbe Rechnung wie bei „Wind-up 8, aktiv ab Frame 9“: Wer ab 1 zählt, zieht beim Umrechnen in absolute Frames eins ab. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: treffer-nach-telegraph + type: output + title: Wann trifft der Gegner? + prompt: | + Gleiche Werte wie zuvor. Die Spielfigur steht in der Fläche und weicht nicht aus. In welchem Frame steht die Zeile `Spieler getroffen …`? Antworte mit der Zahl. + lang: text + code: | + 100 Gegner WAIT -> TELEGRAPH + 112 Gegner TELEGRAPH Stufe 2 + ??? Gegner TELEGRAPH -> ACTIVE + ??? Spieler getroffen … + expected: | + 121 + accept: + - Frame 121 + hints: + - Der Telegraph belegt die Frames 100 bis 119. + - Godot meldet eine Überlappung einen Frame nach dem Einschalten der Hitbox. + explanation: | + Mit `windup = 20` belegt der Telegraph die Frames 100 bis 119, in Frame 120 wechselt der Gegner nach `ACTIVE` und schaltet seine Hitbox ein. Der Physik-Schritt am Ende von Frame 120 erkennt die Überlappung, `area_entered` kommt in Frame 121. Mit Godot 4.7.2 gemessen. Die zweite Stufe ändert an diesem Timing nichts, sie ändert nur, was du siehst. + resources: + - title: Godot Docs · Area2D.area_entered + url: https://docs.godotengine.org/en/stable/classes/class_area2d.html#class-area2d-signal-area-entered + + - id: stufe-zwei-mit-hitstop + type: output + title: Ein Treffer im Telegraph + prompt: | + Gleiche Werte, aber der Gegner hat `interruptible = false`. Die Spielfigur trifft ihn, der Treffer kommt in Frame 104 an, Hitstop 4. Laut Konfliktregel hält der Hitstop den Telegraph an. In welchem Frame steht `Gegner TELEGRAPH Stufe 2`? + lang: text + code: | + 100 Gegner WAIT -> TELEGRAPH + 104 Gegner getroffen: … Frame 4 + ??? Gegner TELEGRAPH Stufe 2 + expected: | + 116 + accept: + - Frame 116 + hints: + - Ohne Treffer wäre es Frame 112. + - Die Frames 104 bis 107 zählen für den Gegner nicht. + explanation: | + Bis Frame 103 zählt der Telegraph die Frames 1 bis 4. Der Treffer in Frame 104 setzt den Hitstop auf 4, die Frames 104 bis 107 stehen still. In Frame 108 läuft Telegraph-Frame 5, und Frame 13 erreicht der Zähler in Frame 116. Kurz: 112 + 4. Genau deshalb steht die Regel in der Vorgabe. Ohne sie könnte der Agent die Stufe auch an die Uhr statt an den Zustand koppeln, und dann käme sie trotz Hitstop in Frame 112. + + - id: sekunden-in-frames + type: gap + title: Notizen in Frames + prompt: | + Du hast dir Werte in Sekunden notiert. Trag sie für die Vorgabe in Frames ein (60 pro Sekunde). `stage2_from` ist der Telegraph-Frame, in dem Stufe 2 beginnt, gezählt ab 1; die Notiz meint „0,2 s nach Beginn des Telegraphs“. + lang: text + code: | + Notiz Vorgabe + Telegraph 0,5 s windup ⟦30⟧ + Hitstop 50 ms hitstop ⟦3⟧ + Stufe 2 nach 0,2 s stage2_from ⟦13⟧ + gaps: + - label: Telegraph in Frames + hint: Frames = Sekunden × 60. + - label: Hitstop in Frames + hint: Frames = ms × 60 / 1000. + - label: Erster Frame von Stufe 2 + hint: 0,2 s sind 12 Frames. Welcher Frame beginnt danach? + explanation: | + 0,5 s × 60 = 30 Frames, 50 ms × 60 / 1000 = 3 Frames. Bei `stage2_from` steckt die Falle im Zählen: 0,2 s sind 12 vergangene Frames, Stufe 2 beginnt also im 13. Telegraph-Frame. Genau wie Wind-up 8 heißt, dass die aktive Phase in Frame 9 beginnt. Wer solche Umrechnungen dem Agenten überlässt, bekommt mal 12, mal 13, und beides sieht im Code gleich richtig aus. + resources: + - title: Godot Docs · Idle and Physics Processing + url: https://docs.godotengine.org/en/stable/tutorials/scripting/idle_and_physics_processing.html + + - id: kurz-vor-active + type: bug + title: Der Agent fragt zurück + prompt: | + Der Agent fragt: „Wann genau beginnt Stufe 2?“ Eine Zeile in der Abnahme lässt das offen. Finde sie und schreib sie im Format der Nachbarzeilen neu. + lang: text + code: | + 3 PARAMETER (Platzhalter) + stage2_from Telegraph-Frame 13 + windup 20 Frames + 6 ABNAHME (Platzhalter, kein Treffer) + WAIT -> TELEGRAPH Frame T + TELEGRAPH Stufe 2 kurz vor ACTIVE + TELEGRAPH -> ACTIVE Frame T + 20 + lines: [6] + fix: + - line: 6 + answers: + - TELEGRAPH Stufe 2 Frame T + 12 + - TELEGRAPH Stufe 2 in Frame T + 12 + - TELEGRAPH Stufe 2 ab Frame T + 12 + - TELEGRAPH Stufe 2 T + 12 + - TELEGRAPH Stufe 2 Frame 12 + T + - TELEGRAPH Stufe 2 Frame T + stage2_from - 1 + - Stufe 2 Frame T + 12 + - Stufe 2 in Frame T + 12 + explanation: | + „Kurz vor ACTIVE“ passt zu Frame T + 19 genauso wie zu T + 10. Der Agent müsste wählen, und jede Wahl bestünde die Abnahme. Mit `stage2_from = 13` beginnt Stufe 2 im 13. Telegraph-Frame, also in Frame T + 12. So prüft die Zeile auch, ob der Agent ab 1 zählt. + resources: + - title: Tim Ryan · The Anatomy of a Design Document, Part 1 + url: https://www.gamedeveloper.com/design/the-anatomy-of-a-design-document-part-1-documentation-guidelines-for-the-game-concept-and-proposal + + - id: iframes-sekunden + type: bug + title: Eine Zeile in Sekunden + prompt: | + Gemeint ist: Die i-Frames beginnen im zweiten Frame des Ausweichens und enden 0,2 Sekunden nach seinem Beginn, wie die Platzhalter in `player.gd`. Eine Zeile mischt die Einheit. Finde sie und schreib sie im Format der Zeile darüber. + lang: text + code: | + VORGABE: i-Frames in der Debug-Anzeige + Werte: Platzhalter aus player.gd + invulnerable_from Dodge-Frame 2 + invulnerable_to nach 0,2 s + Anzeige: Figur blau, solange + is_invulnerable() wahr ist. + lines: [4] + fix: + - line: 4 + answers: + - invulnerable_to Dodge-Frame 12 + - invulnerable_to Frame 12 + - invulnerable_to 12 + - invulnerable_to Dodge-Frame 12 (Platzhalter) + - invulnerable_to Dodge-Frame 12, Platzhalter + - invulnerable_to Dodge-Frame 12 Platzhalter + - invulnerable_to bis Dodge-Frame 12 + - invulnerable_to letzter Dodge-Frame 12 + explanation: | + 0,2 s sind 12 Frames. `invulnerable_to` ist der letzte unverwundbare Frame, also Dodge-Frame 12; ab Frame 13 ist die Figur wieder verwundbar. „Nach 0,2 s“ ließe offen, ob die Zeit ab Frame 1 oder ab dem ersten i-Frame läuft, und der Agent müsste selbst umrechnen. Eine Einheit für alle Dauern verhindert beides. + resources: + - title: "Godot Docs · @export_range" + url: https://docs.godotengine.org/en/stable/classes/class_@gdscript.html#class-gdscript-annotation-export-range + + - id: gefuehl-nicht-abnahme + type: explain + title: „Fühlt sich fair an“ + prompt: | + Eine Vorgabe endet mit der Zeile unten. Erkläre in wenigen Sätzen, warum sie nicht in die Abnahme gehört, was ein Agent damit machen würde und wo der Gedanke stattdessen hingehört. + lang: text + code: | + 6 ABNAHME + Der Telegraph fühlt sich fair an. + points: + - Die Abnahme prüft am Log, ob der Code tut, was in der Vorgabe steht; „fair“ ist ein Urteil, keine Messung. + - Ein Agent, der „fair“ erfüllen soll, ändert Werte nach eigener Annahme und trifft damit Designentscheidungen. + - Das Gefühl beurteilst du selbst beim Spielen, als Experiment zu einer Hypothese wie bei Valve. + - Der Gedanke gehört als Hypothese ins Ziel und als Eindruck ins Testprotokoll; die Abnahme nennt Log-Zeilen mit Frames. + explanation: | + Die Abnahme soll zeigen, dass gebaut ist, was dasteht, etwa „Stufe 2 in Frame T + 12“. „Fair“ lässt sich am Log nicht ablesen. Ein Agent würde trotzdem versuchen, es zu erfüllen, und dafür Werte wie die Dauer der Stufen ändern, also genau die Entscheidungen treffen, die du selbst treffen willst. Valve trennt Design als Hypothese vom Playtest als Experiment. Übertragen heißt das: „Mit zwei Stufen erkenne ich den Angriff früher“ steht als Hypothese im Ziel, und ob es stimmt, notierst du nach dem Spielen im Testprotokoll. + resources: + - title: Valve's Approach to Playtesting (GDC 2009, PDF) + url: https://cdn.fastly.steamstatic.com/apps/valve/2009/GDC2009_ValvesApproachToPlaytesting.pdf + + - id: eigene-vorgabe + type: practice + title: Deine erste Vorgabe + prompt: | + Schreib eine Vorgabe von 6 bis 10 Zeilen für eine neutrale Änderung an der Testszene, nicht für dein Spiel. Vorschläge: „Die Debug-Anzeige zeigt die i-Frames“ oder „Der Trainingsdummy schreibt Treffer je Zone ins Log“. Schreib darunter die Log-Zeilen, die du nach dem Bau erwartest, mit Frame-Nummern aus den Platzhaltern. + lang: text + solution: | + Beispiel für die Form, nicht für Werte: + + VORGABE: i-Frames in der Debug-Anzeige + Ziel: sehen, wann die Figur beim + Ausweichen nicht getroffen wird. + Zustände: keine neuen. + Parameter: iframe_color, Platzhalter blau + Konflikt: Hitstop hält auch die Anzeige. + Debug: Log "i-Frames an" / "i-Frames aus" + Abnahme (Platzhalter 2 bis 12): + DODGE in D, "an" D + 1, "aus" D + 12 + Nicht entscheiden: Werte des Ausweichens + + Vorhersage, Druck in Frame 30: + 30 Spieler MOVE -> DODGE + 31 Spieler i-Frames an + 42 Spieler i-Frames aus + checklist: + - Das Ziel sagt in eigenen Worten, was du sehen oder testen willst. + - Jede Dauer steht in Frames, jede Geschwindigkeit in px/s, und jeder Wert ist als Platzhalter markiert. + - Mindestens eine Konfliktregel steht da, etwa zu Hitstop oder Treffer, notfalls als „entfällt, weil …“. + - Mindestens eine neue Log-Zeile über `HitLog.add()` ist verlangt. + - Die Abnahme nennt Log-Zeilen mit Frame T + n und kein Wort wie „gut“, „fair“ oder „kurz“. + - Eine Zeile sagt, was der Agent nicht entscheiden darf. + - Du hast die erwarteten Log-Zeilen aufgeschrieben, bevor jemand Code schreibt. + explanation: | + Die Form zählt, nicht die Änderung. Im Beispiel gibt es keine neuen Zustände, und das steht ausdrücklich da. Die Abnahme rechnet mit den Platzhaltern aus `player.gd`: i-Frames ab Dodge-Frame 2 heißt „an“ einen Frame nach dem Start, i-Frames bis Frame 12 heißt „aus“ in Dodge-Frame 13, also D + 12. Ob Blau gut sichtbar ist, gehört nicht in die Vorgabe; das entscheidest du, wenn du die Anzeige siehst. + resources: + - title: Ian Schreiber · The Four One-Page Design Docs You Need (GDC 2025) + url: https://gdcvault.com/play/1035120/The-Four-One-Page-Design diff --git a/content/combat/projects.yaml b/content/combat/projects.yaml new file mode 100644 index 0000000..8b16ba0 --- /dev/null +++ b/content/combat/projects.yaml @@ -0,0 +1,205 @@ +projects: + - id: frame-analyse-boss + title: Frame-Analyse eines veröffentlichten Bosses + level: 1 + hours: 6 + summary: Du nimmst einen Bosskampf aus einem Spiel, das du kennst, Bild für Bild auseinander – Telegraphs, aktive Frames, Erholung, Punish-Fenster – und schreibst auf, was ihn lesbar oder unfair macht. Geht größtenteils unterwegs. + brief: | + Wähle einen Boss aus einem veröffentlichten Spiel, am besten einen großen Gegner in der Draufsicht oder einen, dessen Kampf dir im Gedächtnis geblieben ist. Grundlage ist ein Video, das du selbst aufnimmst oder das veröffentlicht ist. Ziel ist eine Analyse, die ein anderer nachprüfen kann: Jede Zahl hat eine Fundstelle im Video. + + Frames zählst du am besten am PC: YouTube spielt mit 0,25-facher Geschwindigkeit ab, und mit den Tasten `,` und `.` springst du in einem pausierten Video Bild für Bild. Am Handy geht die Liste der Angriffe, die Beurteilung der Lesbarkeit und das Aufschreiben. + + Grundlage sind [Angriffsphasen](/combat/angriffsphasen), [Gegnermuster und Telegraphing](/combat/gegnermuster), [Große Kreaturen als Bosse](/combat/grosse-gegner) und [Schwer, unfair oder kaputt?](/combat/frust-diagnose). + skills: [frame-daten, telegraphing, punish-fenster, lesbarkeit, analyse] + steps: + - id: boss-waehlen + title: Boss und Video festlegen + detail: Notiere Spiel, Plattform, Boss und die Quelle des Videos mit Link und Länge. Prüfe, mit wie vielen Bildern pro Sekunde das Video läuft; bei 30 Bildern pro Sekunde ist ein Bild zwei Frames eines 60-Hz-Spiels. + - id: angriffe-auflisten + title: Alle Angriffe benennen + detail: Gib jedem Angriff einen eigenen Namen und notiere, wo im Video er zum ersten Mal vorkommt. Achte auf Varianten, die gleich beginnen und anders enden. + - id: frames-zaehlen + title: Telegraph, aktive Phase und Erholung zählen + detail: Zähle für jeden Angriff Bild für Bild, wie lange die Ankündigung dauert, wie lange er trifft und wie lange der Boss danach steht. Schreib dazu, wie sicher du dir bist; „ab hier sehe ich den Treffer“ ist eine Beobachtung, keine gemessene Hitbox. + - id: kanaele-telegraph + title: Kanäle der Ankündigung festhalten + detail: Notiere je Angriff, womit er sich ankündigt – Pose, Blinken, Bodenmarkierung, Ton – und ob die Ankündigung zeigt, wohin er trifft. + - id: punish-fenster + title: Punish-Fenster bestimmen + detail: Schätze für jeden Angriff, wie viele Frames nach dem Ausweichen bleiben, bevor der Boss wieder handeln kann. Vergleiche die Angriffe untereinander. + - id: lesbarkeit-urteil + title: Lesbarkeit und Fairness beurteilen + detail: Rechne die kürzeste Ankündigung in Millisekunden um und vergleiche sie mit der einfachen Reaktionszeit aus dem Modul [Eingabe](/combat/eingabe). Beschreibe, welcher Angriff dich im Spiel am häufigsten getroffen hat und warum. + - id: fragen-notieren + title: Eigene Fragen notieren + detail: Schreib drei Fragen auf, die diese Analyse für deinen eigenen großen Gegner aufwirft. Antworten brauchst du nicht. + stretch: + - Denselben Boss in einer zweiten Schwierigkeitsstufe oder nach einem Patch vergleichen. + - Eine zweite Person die Ankündigungen beurteilen lassen und die Urteile vergleichen. + portfolio: | + Eine Frame-Analyse zeigt, dass du Kampf in Zahlen und Gründen beschreiben kannst, nicht nur in Eindrücken. Zeig die Tabelle und zwei Standbilder pro Angriff. Im Gespräch fragen Designer, welche Zahl du für die wichtigste hältst und was du ändern würdest, wenn der Boss zu leicht wäre. + + - id: testszene-werkzeug + title: Die Testszene als Messwerkzeug + level: 2 + hours: 10 + summary: Du baust die Testszene der Am-PC-Module vollständig auf und sicherst sie so ab, dass jede spätere Abstimmung auf verlässlichen Messungen steht – Log, Debug-Anzeige, Kollisionsformen und drei Referenzläufe. + brief: | + Bevor du Werte abstimmst, brauchst du ein Werkzeug, dem du traust. Dieses Projekt fasst [Godot-Start](/combat/godot-start), [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) und [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen) zu einer Szene zusammen und prüft sie gegen die Logs aus den Lektionen. Erst wenn deine Szene dieselben Abstände liefert, beurteilst du mit ihr Spielgefühl. + + Den Code dürfen dir die Lektionen oder eine KI liefern. Die Abnahme machst du selbst am Log. + skills: [godot, gdscript, debug, zustandsautomat, kollisionsebenen] + steps: + - id: projekt-einrichten + title: Projekt pixelgenau einrichten + detail: Godot-Version notieren, Stretch Mode `viewport`, Scale Mode `integer`, Filter `Nearest`, Pixel-Snap. Projekt in Git mit einem ersten Commit. + - id: figur-und-dummy + title: Figur, Dummy und Kollisionsebenen + detail: Spielfigur mit `player.gd`, Dummy mit `enemy.gd`, drei benannte Ebenen, Hitbox und Hurtbox nach der Tabelle aus der Lektion. + - id: log-und-anzeige + title: HitLog und Debug-Anzeige + detail: Autoload `HitLog`, Debug-Label im `CanvasLayer`, Kollisionsformen sichtbar. Jeder Tastendruck und jeder Zustandswechsel steht mit Frame-Nummer in der Ausgabe. + - id: referenz-angriff + title: Referenzlauf Angriff + detail: Mit den Platzhalterwerten einmal treffen. Die Abstände im Log müssen der Lektion entsprechen (8 Frames bis `ACTIVE`, Treffer einen Frame danach, `RECOVERY` 7 Frames nach `ACTIVE`, `MOVE` 14 danach). + - id: referenz-ausweichen + title: Referenzläufe Ausweichen + detail: Die drei Fälle aus der Lektion mit dem Drehbuch frame-genau nachstellen – zu spät, gerade noch, zu früh gegen einen langen Angriff – und die Logs speichern. + - id: grosser-gegner + title: Großen Trainingsgegner aufstellen + detail: Zwei Hurtbox-Zonen mit eigenem `zone`-Namen, `interruptible` aus, ein langsamer Platzhalterangriff mit Telegraph so groß wie die Hitbox. + - id: referenz-dokument + title: Referenz festhalten + detail: Eine Datei `REFERENZ.md` mit Godot-Version, Platzhalterwerten und den Logs der Referenzläufe. Ändert sich später etwas Unerwartetes, vergleichst du gegen diese Datei. + stretch: + - Einen Schalter, der alle Kollisionsformen im laufenden Spiel ein- und ausblendet. + - Movie Maker mit PNG-Ausgabe für einen Referenzlauf und die Bilder Frame für Frame mit dem Log vergleichen. + portfolio: | + Ein Prototyp mit sichtbaren Frame-Daten zeigt Arbeitgebern, dass du misst, bevor du urteilst. Ein kurzes Video mit Debug-Anzeige und Log ist im Gespräch mehr wert als eine lange Beschreibung. + + - id: ausweichen-serie + title: Eine Tuning-Serie für das Ausweichen + level: 2 + hours: 8 + summary: Du stimmst das Ausweichen der Testszene in mindestens fünf protokollierten Runden ab – gegen einen festen Trainingsgegner, mit Hypothese vor jeder Änderung und genau einer geänderten Größe pro Runde. + brief: | + Das Ausweichen hängt an mehreren Werten: Anlauf, i-Frames, Strecke, Erholung. Ändert man mehrere zugleich, weiß man hinterher nicht, was gewirkt hat. In diesem Projekt hältst du den Trainingsgegner fest und änderst pro Runde genau einen Wert des Ausweichens. Was sich gut anfühlt, entscheidest du. Das Projekt verlangt nur, dass du deine Entscheidung begründest und belegst. + + Grundlage sind [Ausweichen, Risiko und Belohnung](/combat/ausweichen), [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen) und [Tuning als Experiment](/combat/tuning). + skills: [tuning, i-frames, punish-fenster, protokoll, playtest] + steps: + - id: gegner-festlegen + title: Trainingsgegner festlegen + detail: Wähle zwei Angriffe für den Trainingsgegner, einen kurzen und einen langen, und speichere sie als `.tres`. Sie bleiben die ganze Serie über gleich. + - id: ausgangslage + title: Ausgangslage messen + detail: Notiere die Startwerte des Ausweichens und miss, aus welchen Druck-Frames das Ausweichen gegen beide Angriffe gelingt. + - id: runde-1 + title: Runde 1 + detail: Hypothese mit Ursache und erwarteter Wirkung aufschreiben, genau einen Wert ändern, messen, spielen, Eindruck in eigenen Worten, Entscheidung. + - id: runden-2-bis-5 + title: Runden 2 bis 5 + detail: Wie Runde 1. Jede Runde ist ein eigener Commit mit dem Protokolleintrag. + - id: stumpf-gegen-bewusst + title: Stumpf gegen bewusst + detail: Teste den Endstand zweimal – einmal nur ausweichen, sobald irgendetwas passiert, einmal erst auf den Telegraph reagieren – und vergleiche, welche Spielweise das Ausweichen belohnt. + - id: fremdtest + title: Jemand anderen spielen lassen + detail: Lass eine andere Person fünf Minuten spielen, ohne zu erklären. Notiere, was sie tut, nicht was sie sagt. + - id: abschluss + title: Ergebnis und offene Fragen + detail: Fasse zusammen, welche Runde was gezeigt hat, und schreib die Fragen auf, die offen bleiben. + stretch: + - Dieselbe Serie gegen den großen Trainingsgegner wiederholen und die Ergebnisse vergleichen. + portfolio: | + Eine Tuning-Serie mit Hypothesen und Messungen ist genau die Arbeit, die Studios von Combat Designern erwarten. Zeig das Protokoll und ein kurzes Video vom Anfang und vom Ende der Serie nebeneinander. + + - id: abschluss-kampfentwurf + title: "Abschlussprojekt: Designtest für einen Kampfprototyp" + capstone: true + level: 3 + hours: 40 + summary: Ein kleines Studio schickt dir einen Designtest. Du entwirfst in der Testszene einen eigenen Spielerangriff, ein Ausweichen und einen großen Trainingsgegner mit drei Angriffen, stimmst sie in protokollierten Runden ab und lieferst Frame-Tabelle, Iterationsprotokoll, eine Vorgabe für einen Coding-Agenten und offene Fragen. Alle Designentscheidungen triffst du. + brief: | + ## Auftrag + + Jana Wolff leitet ein Indie-Studio mit sechs Leuten in Leipzig. Das Studio arbeitet an einem Actionspiel in der Draufsicht mit Pixelart, analoger Bewegung in alle Richtungen und großen Gegnern. Für die Stelle „Combat Designer (m/w/d)“ schickt sie dir einen Designtest. Ihre Lage: + + - Der Prototyp des Teams fühlt sich laut Testern „matschig“ an, aber niemand kann sagen, woran das liegt. + - Das Team hat keine Frame-Daten, nur Animationen. Wer ein Timing ändern will, schiebt Keys und hofft. + - Die Programmierer brauchen Vorgaben, nach denen sie ohne Rückfrage bauen können. + + Gesucht ist kein fertiges Kampfsystem, sondern ein Beleg, dass du einen Kampf in Zahlen entwirfst, messbar abstimmst und so beschreibst, dass andere ihn bauen und prüfen können. Den Inhalt des Entwurfs gibt dir niemand vor. Wenn du willst, ist er dein erster Prototyp für Project Delicate Flower; der Kurs schlägt dafür keine Mechanik vor. + + ## Was du lieferst + + 1. **Eine spielbare Szene** in Godot 4.7 auf Grundlage der Testszene: ein Spielerangriff (oder eine kurze Folge), ein Ausweichen und ein großer Trainingsgegner mit mindestens drei Angriffen und mindestens zwei Trefferzonen. + 2. **`FRAME-DATEN.md`:** eine Tabelle aller Angriffe und des Ausweichens in Frames, mit Messung. + 3. **`PROTOKOLL.md`:** mindestens fünf Tuning-Runden mit Hypothese, geänderter Größe, Erwartung, Messung, Eindruck und Entscheidung. + 4. **`VORGABE.md`:** eine Vorgabe für einen Coding-Agenten für eine Erweiterung deiner Wahl, die er ohne Rückfrage umsetzen kann. + 5. **`OFFENE-FRAGEN.md`:** was du noch nicht entschieden hast und wie du es testen würdest. + 6. **Ein Video** von höchstens zwei Minuten mit sichtbarer Debug-Anzeige. + + ## Technischer Rahmen + + Godot 4.7, GDScript, 60 Physik-Frames pro Sekunde, Auflösung und Pixelgröße nach deiner Wahl, Stretch Mode `viewport`. Den Code darf eine KI schreiben, auch nach deiner Vorgabe. Jede Kampfmechanik, jeden Wert und das Urteil über das Spielgefühl entscheidest du selbst. Platzhalter aus dem Kurs, die du übernimmst, kennzeichnest du als solche. + + Grundlage sind alle Module dieses Bereichs, besonders [Angriffsphasen](/combat/angriffsphasen), [Ausweichen, Risiko und Belohnung](/combat/ausweichen), [Große Kreaturen als Bosse](/combat/grosse-gegner), [Trefferfeedback](/combat/trefferfeedback), [Vorgaben für einen Coding-Agenten](/combat/vorgaben) und [Tuning als Experiment](/combat/tuning). + skills: [combat-design, frame-daten, godot, gdscript, tuning, playtest, dokumentation, vorgaben] + steps: + - id: ziel-formulieren + title: Ziel in einem Satz formulieren + detail: Was soll ein Spieler in diesem Kampf erleben und lernen? Schreib den Satz in `PROTOKOLL.md`, bevor du einen Wert festlegst. + - id: spielerwerkzeuge + title: Angriff und Ausweichen entwerfen + detail: Lege Phasen, i-Frames, Strecke und Erholung als erste Hypothese fest und schreib sie in `FRAME-DATEN.md`. Begründe jede Zahl in einem Satz. + - id: gegner-entwerfen + title: Großen Gegner entwerfen + detail: Drei Angriffe mit Telegraph, Fläche und Erholung, zwei Trefferzonen, eine Regel für Unterbrechbarkeit. Halte fest, welcher Angriff was vom Spieler verlangt. + - id: bauen-lassen + title: Bauen oder bauen lassen + detail: Setze den Entwurf in der Testszene um, selbst oder über eine Vorgabe an einen Coding-Agenten. Prüfe das Ergebnis am Log gegen deine Tabelle. + - id: referenzlaeufe + title: Referenzläufe aufnehmen + detail: Je Angriff ein Lauf, in dem du triffst, und einer, in dem du ausweichst. Logs speichern. + - id: runden + title: Fünf Tuning-Runden + detail: Jede Runde ändert genau eine Größe und hat einen eigenen Commit. Spieler- und Gegnerwerte nie in derselben Runde ändern. + - id: stumpf-bewusst + title: Stumpf gegen bewusst testen + detail: Ein Lauf mit Dauerdrücken, einer mit Reagieren auf die Ankündigungen. Beschreibe, welche Spielweise dein Entwurf belohnt, und ob du das so willst. + - id: treffer-erklaeren + title: Jeden Treffer erklären + detail: Für einen vollständigen Lauf gegen den großen Gegner ordnest du jeden Treffer, den die Figur einsteckt, einer Ursache zu – zu spät reagiert, nicht lesbar, technischer Fehler – mit der Log-Zeile als Beleg. + - id: fremdtest + title: Fremdtest + detail: Zwei Personen spielen je fünf Minuten ohne Erklärung. Du notierst, was sie tun, und vergleichst es mit deinem Ziel. + - id: vorgabe-schreiben + title: Vorgabe für einen Coding-Agenten + detail: Wähle eine Erweiterung, etwa einen vierten Angriff oder eine neue Debug-Anzeige, und beschreibe sie so, dass sie ohne Rückfrage umsetzbar und am Log prüfbar ist. + - id: abgabe + title: Abgabe zusammenstellen + detail: Video, Dokumente und ein README mit Godot-Version und Startanleitung. Lies alles einmal als Jana Wolff. + acceptance: + - id: spielbar + text: Die Szene startet mit Godot 4.7 ohne Fehler oder Warnungen in der Ausgabe; alle Aktionen sind mit Tastatur und Gamepad belegt. + - id: frame-tabelle + text: "`FRAME-DATEN.md` nennt für jeden Angriff und das Ausweichen alle Phasen in Frames; für jede Zeile gibt es ein gespeichertes Log, das die Zahlen bestätigt." + - id: runden-protokoll + text: "`PROTOKOLL.md` enthält mindestens fünf Runden; jede hat eine Hypothese, die vor der Änderung datiert ist, genau eine geänderte Größe, eine Messung und eine Entscheidung." + - id: stumpf-bewusst-belegt + text: Je ein Lauf „stumpf angreifen“ und „bewusst reagieren“ gegen den großen Gegner ist mit Ergebnis protokolliert. + - id: treffer-erklaert + text: Für einen vollständigen Lauf ist jeder eingesteckte Treffer mit Ursache und Log-Zeile erklärt. + - id: vorgabe-umsetzbar + text: "`VORGABE.md` nennt Ziel, Zustände, Parameter mit Einheiten, Konfliktregeln und mindestens drei Abnahmekriterien, die man am Log prüfen kann; Werte sind als Parameter angelegt." + - id: offene-fragen + text: "`OFFENE-FRAGEN.md` nennt mindestens drei offene Designfragen und je einen Test, der sie klären würde." + - id: git-verlauf + text: Das Projekt liegt in Git; jede Tuning-Runde ist ein eigener Commit mit Verweis auf ihren Protokolleintrag. + stretch: + - Eine zweite Vergleichsserie, die eine andere Größe als die erste untersucht. + - Ein Fremdtest mit drei Personen und eine Auswertung, worin sich ihr Verhalten unterscheidet. + - Eine Frame-für-Frame-Analyse eines eigenen Laufs mit Movie Maker, gelegt neben das Log. + portfolio: | + Das Projekt ist eine vollständige Fallstudie: Ziel, Entwurf in Zahlen, Messung, fünf Iterationen, Fremdtest, eine Vorgabe für die Umsetzung. Zeig im Portfolio das Video, die Frame-Tabelle und zwei Runden aus dem Protokoll, in denen sich deine Erwartung als falsch herausgestellt hat. Im Gespräch fragen Designer, welche Runde dich am meisten überrascht hat, was du mit mehr Zeit testen würdest und wie du mit einem Animator über einen zu langen Wind-up sprichst. From 36ba3f0d40b1dc6a66c454f6e1ff5f40df838b87 Mon Sep 17 00:00:00 2001 From: Steven Hernandez Date: Sat, 17 Oct 2026 09:05:52 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Combat:=20Ort=20=C3=BCber=20place=20statt?= =?UTF-8?q?=20Hinweis-Callouts,=20Reihenfolgen=20als=20Flie=C3=9Ftext?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit LRN-29 kennt die Plattform `place`. Die 14 Module tragen jetzt `place: unterwegs` (10) oder `place: pc` (4); die Callouts „Unterwegs“ und „Am PC“ am Lektionsanfang und die Präfixe in den Track-Titeln entfallen. Was die Callouts zusätzlich sagten (Clips brauchen Netz, PC-Lektionen lassen sich unterwegs lesen), steht einmal in der Bereichsbeschreibung. Alle 16 order-Übungen bestehen aus deutschen Sätzen und scrollten bei 360 px seitlich; mit `lang: prose` brechen sie um. Co-Authored-By: Claude Opus 5.5 --- content/combat/area.yaml | 20 ++++++++++--------- content/combat/modules/angriffsphasen.yaml | 6 ++---- content/combat/modules/ausweichen.yaml | 6 ++---- content/combat/modules/eingabe.yaml | 6 ++---- content/combat/modules/frust-diagnose.yaml | 6 ++---- content/combat/modules/gegnermuster.yaml | 6 ++---- content/combat/modules/godot-start.yaml | 6 ++---- content/combat/modules/grosse-gegner.yaml | 6 ++---- content/combat/modules/hitboxen.yaml | 6 ++---- content/combat/modules/spielgefuehl.yaml | 8 +++----- content/combat/modules/testszene-angriff.yaml | 6 ++---- .../combat/modules/testszene-ausweichen.yaml | 8 +++----- content/combat/modules/trefferfeedback.yaml | 6 ++---- content/combat/modules/tuning.yaml | 6 ++---- content/combat/modules/vorgaben.yaml | 8 +++----- 15 files changed, 42 insertions(+), 68 deletions(-) diff --git a/content/combat/area.yaml b/content/combat/area.yaml index 19c831a..7fbf8d8 100644 --- a/content/combat/area.yaml +++ b/content/combat/area.yaml @@ -8,7 +8,9 @@ description: > veröffentlichten Spielen und kurzen Clips, die du unterwegs am Handy analysierst. Am PC stimmst du eine kleine Top-down-Testszene in Godot 4 ab: Hypothese, eine Größe ändern, vergleichen. Jede Designentscheidung triffst du - selbst; Code und technisches Gerüst darf eine KI liefern. + selbst; Code und technisches Gerüst darf eine KI liefern. Module mit der Marke + „Unterwegs“ gehen ganz am Handy, nur die verlinkten Clips brauchen Netz. „Am PC“ + braucht Godot 4 am Rechner; lesen kannst du diese Lektionen auch unterwegs. color: "#FF4D6D" order: 11 outcomes: @@ -19,15 +21,15 @@ outcomes: - Du richtest eine **Godot-4-Testszene** in der Draufsicht ein, mit bewegter Figur, `AnimationPlayer` mit Hitbox-Timing, Input Map und Pixel-perfect-Einstellungen. - Du stimmst Kampfwerte als **Experiment** ab – Hypothese, eine Größe, Vergleichstest, Protokoll – und schreibst daraus eine Vorgabe, die ein Coding-Agent umsetzen kann. tracks: - - title: "Unterwegs: Kampf verstehen" - description: Am Handy durcharbeitbar. Spielgefühl, Eingabe und Puffer, die Phasen eines Angriffs, Reichweite und Trefferzonen. + - title: Kampf verstehen + description: Spielgefühl, Eingabe und Puffer, die Phasen eines Angriffs, Reichweite und Trefferzonen. modules: [spielgefuehl, eingabe, angriffsphasen, hitboxen] - - title: "Unterwegs: Ausweichen und Gegner" - description: Am Handy durcharbeitbar. Ausweichen mit Risiko und Belohnung, Gegnermuster und Telegraphing, große Kreaturen als Bosse. + - title: Ausweichen und Gegner + description: Ausweichen mit Risiko und Belohnung, Gegnermuster und Telegraphing, große Kreaturen als Bosse. modules: [ausweichen, gegnermuster, grosse-gegner] - - title: "Unterwegs: Rückmeldung und Diagnose" - description: Am Handy durcharbeitbar. Trefferfeedback, die Frage „schwer, unfair oder kaputt?“ und Vorgaben, nach denen ein Coding-Agent baut. + - title: Rückmeldung und Diagnose + description: Trefferfeedback, die Frage „schwer, unfair oder kaputt?“ und Vorgaben, nach denen ein Coding-Agent baut. modules: [trefferfeedback, frust-diagnose, vorgaben] - - title: "Am PC: Testszene in Godot" - description: Braucht Godot 4 am Rechner. Ein kurzer Godot-Start, dann eine Top-down-Testszene mit Angriff, Ausweichen und Trainingsgegnern, die du in Runden abstimmst. + - title: Testszene in Godot + description: Am PC mit Godot 4. Ein kurzer Godot-Start, dann eine Top-down-Testszene mit Angriff, Ausweichen und Trainingsgegnern, die du in Runden abstimmst. modules: [godot-start, testszene-angriff, testszene-ausweichen, tuning] diff --git a/content/combat/modules/angriffsphasen.yaml b/content/combat/modules/angriffsphasen.yaml index 1632379..2ba5946 100644 --- a/content/combat/modules/angriffsphasen.yaml +++ b/content/combat/modules/angriffsphasen.yaml @@ -3,6 +3,7 @@ title: "Anatomie eines Angriffs: Wind-up, aktiv, Erholung" level: 2 minutes: 100 summary: Wie ein Angriff in Phasen zerfällt, wie du offizielle Frame-Daten liest und in die Zählung der Testszene umrechnest, und was Wind-up, aktive Phase, Erholung und Abbruchfenster für Rückmeldung, Lesbarkeit, Wucht und Risiko leisten. +place: unterwegs goals: - Du benennst die vier Phasen eines Angriffs nach Sakurai und ordnest ihnen Wind-up, aktive Phase und Erholung zu. - Du liest offizielle Frame-Daten und rechnest Startup und Active in Wind-up, aktive Frames und Gesamtdauer um. @@ -21,9 +22,6 @@ resources: - title: Mariel Cartwright · Fluid and Powerful Animation within Frame Restrictions (GDC 2014) url: https://www.youtube.com/watch?v=Mw0h9WmBlsw lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - In der Testszene drückst du in Frame 12 auf Angriff. Die Hitbox geht in Frame 20 an, der Dummy meldet den Treffer in Frame 21, und frei ist die Figur erst wieder in Frame 41. Von diesen 29 Frames, knapp einer halben Sekunde, ist die Hitbox 3 Frames lang wirksam, 4 weitere friert der Hitstop ein. Den Rest der Zeit holt die Figur aus oder erholt sich. Das ist kein Sonderfall: Laut dem Fighting Game Glossary von Infil haben die meisten Angriffe nur etwa 1 bis 4 aktive Frames, obwohl der ganze Angriff 30 Frames dauern kann ([Active](https://glossary.infil.net/?t=Active)). Wer einen Angriff beurteilen will, muss ihn deshalb zerlegen: Wann trifft er, wie lange, und was kostet er danach? Diese Lektion trennt die Phasen, liest offizielle Frame-Daten und zeigt, welche Aufgabe jede Phase für den Spieler hat. Welche Werte deine Angriffe bekommen, entscheidest du später selbst. @@ -281,7 +279,7 @@ exercises: title: Ein Angriff von vorn bis hinten prompt: | Bring die Ereignisse eines Angriffs mit Treffer und Abbruchfenster in die Reihenfolge, in der sie passieren. - lang: text + lang: prose lines: | Taste gedrückt, die Figur springt in die Ausholpose Der Wind-up läuft, die Hitbox ist noch aus diff --git a/content/combat/modules/ausweichen.yaml b/content/combat/modules/ausweichen.yaml index 8f1fb66..4e34b3d 100644 --- a/content/combat/modules/ausweichen.yaml +++ b/content/combat/modules/ausweichen.yaml @@ -3,6 +3,7 @@ title: Ausweichen, Risiko und Belohnung level: 2 minutes: 110 summary: Ein Ausweichen in Frames zerlegen – Anlauf, i-Frames, Weg, Erholung –, aus einem Log ablesen, ob ein Druck rechtzeitig war, und beurteilen, was ein Ausweichen kostet und was es einbringt. +place: unterwegs goals: - Du zerlegst ein Ausweichen in Anlauf, i-Frames, Weg und Erholung und rechnest die Phasen in Frames und Pixel um. - Du sagst aus einem Log vorher, ob ein Druck rechtzeitig war und in welchem Frame die i-Frames enden. @@ -19,9 +20,6 @@ resources: - title: Q&A · Enter the Gungeon (Game Developer) url: https://www.gamedeveloper.com/design/q-a-the-guns-and-dungeons-of-i-enter-the-gungeon-i- lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Der Trainingsgegner der Testszene (im Log `Dummy`) kündigt seinen Angriff 20 Frames lang mit einer gelben Bodenfläche an, in Frame 30 geht seine Hitbox an. „Druck in Frame N“ heißt in diesem Modul: Die Zeile `Druck dodge` steht in Frame N im Log, die Figur hat den Druck in diesem Frame gesehen. Kommt der Druck in Frame 29, steht im Log kein Treffer. Kommt er in Frame 30, steht dort `Spieler getroffen in DODGE, Frame 1`. Ein Frame ist ein Physik-Tick, 1/60 Sekunde. Zwischen „ausgewichen“ und „getroffen“ liegen also 16,7 Millisekunden, und das Log zeigt, woran es lag. Ausweichen gehört zum Kern vieler Actionspiele. In Enter the Gungeon war die Ausweichrolle laut Dave Crooks „the first mechanic other than shooting that went into the game“, angelehnt an die Rolle mit i-Frames aus der Souls-Reihe ([Game Developer](https://www.gamedeveloper.com/design/q-a-the-guns-and-dungeons-of-i-enter-the-gungeon-i-)). Dieses Modul zerlegt ein Ausweichen in Frames, trennt zwei Arten von Schutz und fragt, was ein Ausweichen kosten muss, damit es eine Entscheidung bleibt. Alle Werte der Testszene sind Platzhalter. Welche Werte dein Spiel bekommt, entscheidest du. @@ -234,7 +232,7 @@ exercises: title: Von der Beschwerde zur Änderung prompt: | Ein Tester sagt: „Ich weiche aus und werde trotzdem getroffen.“ Bring die Schritte in eine sinnvolle Reihenfolge. „Phase“ heißt hier: Anlauf, nach den i-Frames oder gar nicht im Ausweichen. - lang: text + lang: prose lines: | Situation mit Log nachstellen Treffer-Zeile suchen diff --git a/content/combat/modules/eingabe.yaml b/content/combat/modules/eingabe.yaml index 58c87e9..19b6210 100644 --- a/content/combat/modules/eingabe.yaml +++ b/content/combat/modules/eingabe.yaml @@ -3,6 +3,7 @@ title: "Eingabe: Reaktionszeit, Latenz und Puffer" level: 1 minutes: 100 summary: Wie schnell Menschen reagieren, wie viel Zeit zwischen Daumen und Bildschirm vergeht und wie Spiele mit Puffer, Coyote Time und Abbruchfenstern Drücke retten, die nicht genau im richtigen Frame kommen – mit einem Python-Modell des Puffers der Testszene. +place: unterwegs goals: - Du nennst die einfache Reaktionszeit nach Woods et al. in Millisekunden und Frames und erklärst, warum sie im Kampf eine Untergrenze ist. - Du beschreibst die Latenzkette vom Controller bis zum Bildschirm und wie ein Druck in Godot bei `_physics_process` ankommt. @@ -21,9 +22,6 @@ resources: - title: "Ian Cofino · Dreamscaper: Killer Combat on an Indie Budget (GDC 2022)" url: https://www.youtube.com/watch?v=3Omb5exWpd4 lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Ein echtes Log aus der Testszene, nur die Zeilen der Figur. Sie steckt nach einem Treffer in der Erholung, und du drückst erst Ausweichen, dann Angriff: ```text @@ -232,7 +230,7 @@ exercises: title: Vom Daumen bis zum Bild prompt: | Bring die Stationen eines Ausweich-Drucks in Godot in die richtige Reihenfolge. - lang: text + lang: prose lines: | Der Daumen drückt, der Controller sendet den Druck Godot gibt das Event mit dem nächsten Bild weiter diff --git a/content/combat/modules/frust-diagnose.yaml b/content/combat/modules/frust-diagnose.yaml index 3486c48..d5a6e8a 100644 --- a/content/combat/modules/frust-diagnose.yaml +++ b/content/combat/modules/frust-diagnose.yaml @@ -3,6 +3,7 @@ title: Schwer, unfair oder kaputt? Treffer nachvollziehen level: 2 minutes: 100 summary: Fühlt sich ein Treffer falsch an, hat das eine von drei Ursachen – der Kampf ist schwer, der Angriff war nicht lesbar, oder der Code tut etwas anderes als gedacht. Du trennst sie mit Reaktionszeit, Log und Einzelbild, bevor du einen Wert änderst. +place: unterwegs goals: - Du ordnest einen unerwarteten Treffer einer von drei Ursachen zu – Schwierigkeit, mangelnde Lesbarkeit oder technischer Fehler – und begründest die Zuordnung am Log. - Du rechnest einen Telegraph in Millisekunden um und vergleichst ihn mit gemessenen Reaktionszeiten. @@ -21,9 +22,6 @@ resources: - title: Godot Docs · Overview of debugging tools url: https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Drei Treffer, derselbe Satz: „Ich bin doch ausgewichen!“ Im ersten Fall steht im Log der Testszene `Druck dodge` in Frame 22. Die Figur wich auf der Stelle aus, ihre i-Frames liefen bis Frame 33. Die Hitbox des Gegners ging in Frame 30 an und blieb 20 Frames aktiv, also kam der Treffer in Frame 35, gleich nach den i-Frames. Das Ausweichen kam zu früh. Im zweiten Fall kam der Angriff von einem Gegner außerhalb des Bildes. Im dritten traf eine Hitbox an einer Stelle, an der das Bild nichts zeigte. Der erste Fall ist ein echter Lauf, die anderen beiden sind ausgedacht. Alle drei fühlen sich gleich an und brauchen verschiedene Antworten: üben, die Ankündigung ändern, den Code reparieren. Dieses Modul trennt die drei Ursachen und zeigt, wie du einen Treffer Frame für Frame nachvollziehst. Ob ein Kampf danach schwerer oder leichter werden soll, entscheidest du. @@ -249,7 +247,7 @@ exercises: title: Ein seltener Treffer, gezielt nachgestellt prompt: | Ein Tester meldet einen Treffer, der „nur manchmal“ passiert. Bring die Schritte in eine Reihenfolge, mit der du ihn am PC gezielt nachstellst. - lang: text + lang: prose lines: | Meldung mit Uhrzeit oder Frame notieren Den Moment in Log und Aufnahme suchen diff --git a/content/combat/modules/gegnermuster.yaml b/content/combat/modules/gegnermuster.yaml index 2db4d00..e9736ed 100644 --- a/content/combat/modules/gegnermuster.yaml +++ b/content/combat/modules/gegnermuster.yaml @@ -3,6 +3,7 @@ title: Gegnermuster und Telegraphing level: 2 minutes: 110 summary: Wofür ein Gegner im Kampf da ist, wer wann angreifen darf, über welche Kanäle sich ein Angriff ankündigt und warum Muster besser lesbar sind als Zufall – an God of War, Monster Hunter, Hades und dem Trainingsgegner der Testszene. +place: unterwegs goals: - Du beschreibst die Rolle eines Gegners als Frage an den Spieler und ordnest ihr messbare Größen zu. - Du erklärst Aggressions-Tokens und den Kung-Fu-Kreis aus God of War und rechnest aus, wer aggressiv wird. @@ -19,9 +20,6 @@ resources: - title: Vice · The Developers of Monster Hunter Explain What It's Like to Build Monsters url: https://vice.com/en/article/the-developers-of-monster-hunter-explain-what-its-like-to-build-monsters lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Der Trainingsgegner der Testszene besteht aus wenigen Zahlen: 60 Frames warten, 20 Frames gelbe Bodenfläche, 4 Frames Hitbox, 30 Frames Erholung. Ein Frame ist ein Physik-Tick, 1/60 Sekunde. Das reicht für einen Angriff. Ein Gegner wird daraus erst, wenn klar ist, wann er angreift, woran der Spieler das erkennt und was danach kommt. Kaname Fujioka beschreibt die frühen Monster von Monster Hunter mit „relatively simple attacks, easy tells“: Wenn ein Monster das tut, weißt du, dass es gleich auf dich zustürmt ([Vice](https://vice.com/en/article/the-developers-of-monster-hunter-explain-what-its-like-to-build-monsters)). Ein **Telegraph** ist die Ankündigung eines Angriffs, ein **Tell** das einzelne Signal darin. Dieses Modul behandelt die Bausteine: welche Rolle ein Gegner spielt, wer wann angreifen darf, über welche Kanäle sich ein Angriff ankündigt, warum Muster lesbarer sind als Zufall und wie Studios das nach Playtests nachstellen. Alle Werte der Testszene sind Platzhalter. Wie deine Gegner aussehen, entscheidest du. @@ -209,7 +207,7 @@ exercises: title: Ein Zyklus des Trainingsgegners prompt: | Bring die Zustände des Trainingsgegners in die Reihenfolge, in der sie für Angriff A ablaufen, beginnend mit der Pause vor A. - lang: text + lang: prose lines: | WAIT: Pause vor Angriff A TELEGRAPH: gelbe Fläche sichtbar diff --git a/content/combat/modules/godot-start.yaml b/content/combat/modules/godot-start.yaml index 8bec327..b058c35 100644 --- a/content/combat/modules/godot-start.yaml +++ b/content/combat/modules/godot-start.yaml @@ -3,6 +3,7 @@ title: "Godot-Start: genug Godot 4 für die Testszene" level: 1 minutes: 150 summary: Godot 4.7 unter Linux installieren, ein Projekt pixelgenau einrichten, eine Figur in alle Richtungen laufen lassen und einen Angriff als Animation bauen, deren Hitbox genau drei Frames lang trifft. +place: pc goals: - Du installierst Godot 4.7 unter CachyOS und prüfst, welche Version läuft. - Du erklärst Node, Szene und Szenenbaum an der Testszene und legst die Nodes selbst an. @@ -21,9 +22,6 @@ resources: - title: Godot Docs · AnimationMixer url: https://docs.godotengine.org/en/stable/classes/class_animationmixer.html lesson: | - > [!NOTE] Am PC - > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. - In den Unterwegs-Modulen rechnest du mit Frames: acht Frames Wind-up, drei aktiv, vierzehn Erholung. Ob sich das gut anfühlt, merkst du erst, wenn du es spielst. Dafür baust du am PC eine kleine Testszene in der Draufsicht: eine Figur, die analog in alle Richtungen läuft, ein Angriff mit einer Hitbox und später Trainingsgegner. Dieses Modul bringt dir genau so viel Godot bei, wie diese Szene braucht. Den ganzen Editor, Import von Sprites, Export und Profiler behandelt der geplante Godot-Kurs. Am Ende des Moduls läuft eine Figur mit Tastatur oder Gamepad durch ein 320 × 180 Pixel großes Bild, und ein Tastendruck spielt einen Angriff ab, dessen Hitbox in den Frames 9 bis 11 aktiv ist. Den Code darf dir eine KI schreiben, die Werte bestimmst du. Die Startwerte hier sind Platzhalter, damit überhaupt etwas läuft. @@ -266,7 +264,7 @@ exercises: title: Ein Projekt für Pixelart einrichten prompt: | Bring die Schritte in eine sinnvolle Reihenfolge, vom Download bis zur ersten Probe der Bewegung. - lang: text + lang: prose lines: | Prüfsumme der ZIP-Datei mit sha512sum kontrollieren Godot starten und im Projektmanager ein neues Projekt anlegen diff --git a/content/combat/modules/grosse-gegner.yaml b/content/combat/modules/grosse-gegner.yaml index 5947bb4..f887330 100644 --- a/content/combat/modules/grosse-gegner.yaml +++ b/content/combat/modules/grosse-gegner.yaml @@ -3,6 +3,7 @@ title: Große Kreaturen als Bosse level: 3 minutes: 120 summary: Was ein großer Gegner in der Draufsicht anders macht – Größe und Lesbarkeit, Hurtbox-Zonen und Schwachpunkte, Phasen, Kamera, Vorhersagbarkeit und Unterbrechbarkeit –, an veröffentlichten Spielen und dem neutralen großen Trainingsgegner der Testszene. +place: unterwegs goals: - Du rechnest aus, ob ein Telegraph reicht, um eine Fläche zu Fuß oder mit dem Ausweichen zu verlassen. - Du erklärst, wie Hurtbox-Zonen und Schwachpunkte mit der Besiegmethode zusammenhängen, an Hades und Shadow of the Colossus. @@ -19,9 +20,6 @@ resources: - title: GDC 2024 · Refining the Rogues Gallery – Boss Fights in Marvel's Spider-Man 2 url: https://gdcvault.com/play/1034367/Refining-the-Rogues-Gallery-Building lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Für Shadow of the Colossus gab Fumito Ueda den Grafikern laut ihrem Kollegen Suzuki nur zwei Dinge vor: „the size of the Colossus and the pixel resolution of the screen“ ([GlitterBerri](https://glitterberri.com/extended-developer-interview/the-graphic-designers-tell-it-straight/)). Größe und Auflösung zusammen bestimmen, was der Spieler von einem großen Gegner überhaupt sehen kann. In einem Spiel mit 320 × 180 Pixeln in der Draufsicht ist das die erste Frage bei jeder großen Kreatur. Du entwirfst eigene große Kreaturen als Bosse. Dieses Modul entwirft keine davon. Es zeigt, welche Fragen veröffentlichte Spiele an große Gegner gestellt haben, und rechnet am **großen Trainingsgegner** der Testszene: dieselbe Logik wie der Trainingsgegner, aber groß, mit den Hurtbox-Zonen `kopf` und `bein` und einer großen Angriffsfläche. Alle Werte dort sind Platzhalter. Die Antworten für deine Kreaturen findest du selbst. @@ -196,7 +194,7 @@ exercises: title: Kommt die Figur heraus? prompt: | Bring die Schritte der Rechnung in eine richtige Reihenfolge: Reicht ein Telegraph, um zu Fuß aus einer Fläche zu kommen? - lang: text + lang: prose lines: | Weg bis zum Rand messen (Pixel) mal 60 nehmen diff --git a/content/combat/modules/hitboxen.yaml b/content/combat/modules/hitboxen.yaml index 468a495..25fb2d1 100644 --- a/content/combat/modules/hitboxen.yaml +++ b/content/combat/modules/hitboxen.yaml @@ -3,6 +3,7 @@ title: Reichweite, Hitboxen und Hurtboxen in der Draufsicht level: 2 minutes: 100 summary: Was Hitbox und Hurtbox sind, warum Sakurai eine Hitbox größer als die Waffe erlaubt, aber nicht an anderer Stelle, und wie du Reichweite in der Draufsicht nachrechnest, wenn Figuren sich in alle Richtungen drehen und bewegen. +place: unterwegs goals: - Du unterscheidest Hitbox, Hurtbox und Disjointed Hitbox und erklärst, wo sie vom Bild abweichen dürfen und wo nicht. - Du prüfst, ob sich zwei achsenparallele Rechtecke oder zwei Kreise überlappen, und rechnest daraus die Reichweite eines Angriffs. @@ -21,9 +22,6 @@ resources: - title: Godot Docs · Overview of debugging tools · Visible collision shapes url: https://docs.godotengine.org/en/stable/tutorials/scripting/debug/overview_of_debugging_tools.html#visible-collision-shapes lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - In der Testszene steht der Dummy 20 Pixel rechts von der Spielfigur. Die Hitbox des Platzhalterangriffs ist 14 × 10 Pixel groß, ihre Mitte sitzt 12 Pixel vor der Figur, ihr vorderer Rand also 19 Pixel vor der Figurenmitte. Die Hurtbox des Dummys ist 12 Pixel breit, ihr linker Rand liegt 14 Pixel vor der Figurenmitte. Die Flächen überlappen 5 Pixel, und das Log meldet `Dummy getroffen`. Mit Godot 4.7.2 nachgemessen: Schiebst du den Dummy auf 25 Pixel, berühren sich die Ränder nur noch, und es trifft trotzdem. Bei 26 Pixeln trifft der Angriff nicht mehr. Ob ein Angriff trifft, entscheidet Geometrie in den Frames, in denen die Hitbox an ist. Diese Lektion klärt, was Hitbox und Hurtbox sind, warum sie vom Bild abweichen dürfen und wie du Reichweite in der Draufsicht nachrechnest. Für Reichweite und Drehung in der Draufsicht nennt keine der Quellen dieses Kurses Regeln; Ian Cofino beschreibt nur, wie großzügig Dreamscaper die Flächen setzt. Was dazu folgt, ist Rechnung und Messung an der Testszene mit Platzhalterwerten, keine Regel aus einem Studio. @@ -233,7 +231,7 @@ exercises: title: Vom Wind-up zum Hitstop prompt: | Bring die Schritte eines Treffers in der Testszene in die Reihenfolge, in der sie passieren. - lang: text + lang: prose lines: | Der Wind-up endet, die Figur wechselt nach ACTIVE Die Form der Hitbox wird mit set_deferred eingeschaltet diff --git a/content/combat/modules/spielgefuehl.yaml b/content/combat/modules/spielgefuehl.yaml index 830edd4..50fa58f 100644 --- a/content/combat/modules/spielgefuehl.yaml +++ b/content/combat/modules/spielgefuehl.yaml @@ -3,6 +3,7 @@ title: "Spielgefühl: Steuerung, Reaktion, Rückmeldung" level: 1 minutes: 75 summary: Was Game Feel ist, wie Eingabe, Simulation und Rückmeldung einen Kreislauf bilden und warum dieser Bereich Spielgefühl in Frames misst – mit Messwerten veröffentlichter Spiele und Sakurais Zähltrick. +place: unterwegs goals: - Du erklärst Game Feel mit Swinks sechs Bestandteilen und ordnest Beispiele aus Actionspielen zu. - Du beschreibst den Kreislauf aus Eingabe, Simulation und Rückmeldung und sagst, an welchen Stationen Zeit vergeht. @@ -19,9 +20,6 @@ resources: - title: Jonasson & Purho · Juice it or lose it url: https://www.youtube.com/watch?v=Fy0aCDmgnxg lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Stell dir zwei Fassungen desselben Schwertschlags vor. Beide treffen 9 Frames nach dem Druck, beide machen gleich viel Schaden. In der ersten springt die Figur im ersten Frame in eine Ausholpose. In der zweiten steht sie vier Frames still und holt erst dann aus. Auf dem Papier sind beide gleich schnell. Im Spiel siehst du bei der zweiten vier Frames lang nicht, ob dein Druck angekommen ist. Masahiro Sakurai, der Director von Super Smash Bros., baut Angriffe deshalb so, dass der erste Frame sofort eine Reaktion zeigt. Dieses Modul legt das Denkmodell für den ganzen Bereich. Es klärt, was Game Feel ist, wie Eingabe, Simulation und Rückmeldung zusammenspielen und warum du Spielgefühl in Frames misst statt in Adjektiven. Du hast eine Draufsicht im Kopf, analoge Bewegung in alle Richtungen und Pixelart. Welche Werte dein Spiel bekommt, entscheidest du. Hier lernst du, sie zu benennen, zu messen und zu vergleichen. @@ -226,7 +224,7 @@ exercises: title: Einmal durch den Kreislauf prompt: | Bring die Schritte in die Reihenfolge, in der sie ablaufen, von der Absicht bis zur nächsten Entscheidung. - lang: text + lang: prose lines: | Du willst dem Angriff ausweichen Du drückst die Ausweichtaste @@ -245,7 +243,7 @@ exercises: title: Von kurz nach lang prompt: | Sortiere die Zeiten von der kürzesten zur längsten. Rechne dafür alles in Millisekunden um (ein Frame ≈ 16,7 ms). - lang: text + lang: prose lines: | PS3-Systemmenü bei Mick West: 3/60 s Typisches 60-fps-Spiel bei Mick West: 4/60 s diff --git a/content/combat/modules/testszene-angriff.yaml b/content/combat/modules/testszene-angriff.yaml index 7b0fdad..5fe1056 100644 --- a/content/combat/modules/testszene-angriff.yaml +++ b/content/combat/modules/testszene-angriff.yaml @@ -3,6 +3,7 @@ title: "Testszene: Angriff mit Frame-Daten" level: 2 minutes: 180 summary: Ein Angriff, dessen Wind-up, aktive Phase und Erholung als Zahlen im Inspektor stehen, ein Zustandsautomat, der Physik-Frames zählt, ein Eingabepuffer, ein Trainingsdummy mit Hurtbox, Hitstop und eine Anzeige, die jeden Treffer mit Frame-Nummer protokolliert. +place: pc goals: - Du legst Frame-Daten als `Resource` an und änderst sie im Inspektor, ohne Code anzufassen. - Du liest einen Zustandsautomaten, der Physik-Frames zählt, und sagst vorher, in welchem Frame ein Zustandswechsel im Log steht. @@ -19,9 +20,6 @@ resources: - title: Godot Docs · Engine.time_scale url: https://docs.godotengine.org/en/stable/classes/class_engine.html#class-engine-property-time-scale lesson: | - > [!NOTE] Am PC - > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. - Im [Godot-Start](/combat/godot-start) steckte das Timing des Angriffs in Keys einer Animation. Wer acht Frames Wind-up auf zehn ändern will, verschiebt dort zwei Keys und hofft, dass er keinen dritten vergessen hat. Zum Abstimmen willst du etwas anderes: Zahlen im Inspektor, einen Dummy zum Draufhauen und ein Log, das sagt, in welchem Frame was passiert ist. Dieses Modul baut genau das. Die Skripte darf dir eine KI schreiben, und hier stehen sie fertig. Lesen solltest du sie trotzdem: Wenn sich ein Treffer falsch anfühlt, musst du unterscheiden können, ob dein Wert nicht passt oder der Code etwas anderes tut, als du denkst. Alle Zahlen in den Skripten sind Platzhalter. Sie sind so gewählt, dass die Szene läuft, nicht so, dass sie sich gut anfühlt. Das Abstimmen ist deine Arbeit in [Tuning als Experiment](/combat/tuning). @@ -604,7 +602,7 @@ exercises: title: Was in einem Frame passiert prompt: | Bring die Schritte von `_physics_process` der Spielfigur in die Reihenfolge, in der sie in einem Frame ablaufen. - lang: text + lang: prose lines: | Tastendrücke lesen und neuen Druck merken Im Hitstop: Zähler verringern und den Frame beenden diff --git a/content/combat/modules/testszene-ausweichen.yaml b/content/combat/modules/testszene-ausweichen.yaml index d76eb96..67baad0 100644 --- a/content/combat/modules/testszene-ausweichen.yaml +++ b/content/combat/modules/testszene-ausweichen.yaml @@ -2,7 +2,8 @@ id: testszene-ausweichen title: "Testszene: Ausweichen und Trainingsgegner" level: 2 minutes: 180 -summary: Die Figur bekommt ein Ausweichen mit i-Frames, der Dummy einen angekündigten Angriff, und ein großer Trainingsgegner mit zwei Trefferzonen kommt dazu. Unterwegs findest du einen Fehler, der nur mit i-Frames auftritt, und liest an Logs ab, wer nach einer Aktion zuerst wieder handeln kann. +summary: Die Figur bekommt ein Ausweichen mit i-Frames, der Dummy einen angekündigten Angriff, und ein großer Trainingsgegner mit zwei Trefferzonen kommt dazu. Dabei findest du einen Fehler, der nur mit i-Frames auftritt, und liest an Logs ab, wer nach einer Aktion zuerst wieder handeln kann. +place: pc goals: - Du baust ein Ausweichen mit einstellbaren i-Frames und sagst vorher, in welchem Frame ein Treffer ankommt oder ausbleibt. - Du erklärst, warum die Testszene die Hurtbox während der i-Frames abschaltet, statt Treffer nur abzulehnen. @@ -19,9 +20,6 @@ resources: - title: GDC 2021 · Melee AI in The Last of Us Part II (Folien) url: https://media.gdcvault.com/GDC+2021/Melee+AI+in+The+Last+of+Us+Part+II.pdf lesson: | - > [!NOTE] Am PC - > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. - Bisher hält der Dummy still. Jetzt schlägt er zurück, und die Figur bekommt ein Ausweichen. Damit kommen die Fragen, um die es in [Ausweichen, Risiko und Belohnung](/combat/ausweichen) geht, in deine Szene: Ab welchem Frame schützt das Ausweichen? Wann bin ich wieder verwundbar? Und wie lange bleibt mir danach, um zurückzuschlagen? Das Modul baut auf [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff) auf. `enemy.gd`, `hurtbox.gd`, `attack_data.gd` und `hit_log.gd` bleiben, wie sie sind; `player.gd` ersetzt du am Ende durch die neue Fassung. Alle Zahlen sind wieder Platzhalter. @@ -483,7 +481,7 @@ exercises: title: Ein Ausweichen von Anfang bis Ende prompt: | Bring die Ereignisse eines Ausweichens mit den Platzhaltern (18 Frames, i-Frames 2 bis 12, Erholung 8) in die richtige Reihenfolge. - lang: text + lang: prose lines: | Druck dodge, die Figur wechselt nach DODGE (Frame 1, verwundbar) i-Frames an, die Hurtbox ist aus (Frame 2) diff --git a/content/combat/modules/trefferfeedback.yaml b/content/combat/modules/trefferfeedback.yaml index f1479b3..aa095b8 100644 --- a/content/combat/modules/trefferfeedback.yaml +++ b/content/combat/modules/trefferfeedback.yaml @@ -3,6 +3,7 @@ title: "Trefferfeedback: Hitstop, Screenshake, Blitz und Ton" level: 2 minutes: 100 summary: Was im Moment eines Treffers passiert, damit der Spieler ihn bemerkt – Hitstop, Screenshake, Blitz, Trefferzeichen, Knockback und Ton –, wie veröffentlichte Spiele diese Mittel dosieren und was jedes davon kostet. +place: unterwegs goals: - Du beschreibst Hitstop mit Sakurais Techniken und rechnest aus, um wie viele Frames er eine Aktion verlängert. - Du begründest mit dem gemessenen Verhalten von Godot 4.7.2, warum die Testszene Figuren einzeln anhält statt über `Engine.time_scale`. @@ -21,9 +22,6 @@ resources: - title: Godot Docs · Camera2D.offset url: https://docs.godotengine.org/en/stable/classes/class_camera2d.html#class-camera2d-property-offset lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Im Log der Testszene trifft der Angriff den Dummy in Frame 21. Was du in diesem Moment siehst, steht nicht im Log: Beide Quadrate halten 4 Frames an, der Dummy leuchtet auf und wird mit 120 Pixeln pro Sekunde zurückgestoßen. Ohne diese Signale hätte die Kollision genauso gezählt. Ob der Spieler den Treffer bemerkt, hängt an ihnen. Dieses Modul geht die Mittel einzeln durch: Hitstop, Screenshake, Blitz und Trefferzeichen, Knockback, Ton. Zu jedem steht, was es tut, wie veröffentlichte Spiele es dosieren und was es kostet. Wie viel Rückmeldung dein Spiel bekommt, entscheidest du. Die Zahlen der Testszene sind Platzhalter. @@ -246,7 +244,7 @@ exercises: title: Ein Treffer von Anfang bis Ende prompt: | Bring die Ereignisse des echten Testszenen-Laufs aus der Lektion (Hitstop 4, Hitstun 12) in die Reihenfolge, in der sie passieren. - lang: text + lang: prose lines: | Die Hitbox des Spielers geht an Das Signal meldet den Treffer, beide setzen hitstop_left diff --git a/content/combat/modules/tuning.yaml b/content/combat/modules/tuning.yaml index a8105c9..dfae256 100644 --- a/content/combat/modules/tuning.yaml +++ b/content/combat/modules/tuning.yaml @@ -3,6 +3,7 @@ title: Tuning als Experiment level: 3 minutes: 180 summary: Kampfwerte als Experiment abstimmen – Hypothese vorher, genau eine Größe ändern, Variante A gegen B mit Mash-Bot und Reaktionstest vergleichen, Treffer Bild für Bild nachvollziehen und jede Runde im Protokoll festhalten. +place: pc goals: - Du schreibst eine Hypothese mit Ursache und erwarteter Wirkung auf, bevor du einen Wert änderst. - Du legst Varianten als `.tres`-Dateien an und prüfst an den Kopfzeilen des Logs, dass sie sich in genau einer Größe unterscheiden. @@ -21,9 +22,6 @@ resources: - title: Playtesting – Avoiding Evil Data (Adriaan de Jongh, GDC 2017) url: https://www.youtube.com/watch?v=6EUeYu0aPn4 lesson: | - > [!NOTE] Am PC - > Für diese Lektion brauchst du Godot 4 am Rechner. Lesen kannst du sie auch unterwegs, die Übungen setzen die Testszene voraus. - Der Trainingsgegner erwischt dich zu oft. Du verlängerst seinen Telegraph, gibst deinem Ausweichen zwei i-Frames mehr und kürzt seine Erholung. Danach fühlt sich der Kampf besser an. Welche der drei Änderungen hat gewirkt? Vielleicht nur eine, vielleicht hebt eine die andere auf. Du weißt es nicht, und beim nächsten Gegner fängst du wieder bei null an. Dieses Modul macht aus dem Abstimmen (Tuning) ein Experiment: eine Vermutung vorher, eine Änderung, ein Vergleich, ein Protokoll. Es setzt die Szene aus [Testszene: Ausweichen und Trainingsgegner](/combat/testszene-ausweichen) voraus. Neu ist ein Node, der Version und Werte ins Log schreibt und stumpfes Angreifen nachstellt. Der Code ist Gerüst. Welche Werte du ausprobierst und ob dir ein Ergebnis gefällt, entscheidest du. Alle Zahlen hier sind Platzhalter der Testszene. @@ -330,7 +328,7 @@ exercises: title: Eine Runde in der richtigen Reihenfolge prompt: | Bring die Schritte einer Tuning-Runde in eine sinnvolle Reihenfolge. - lang: text + lang: prose lines: | Hypothese mit Ursache und erwarteter Wirkung aufschreiben gegner_a.tres im Dateisystem-Dock als gegner_b.tres duplizieren diff --git a/content/combat/modules/vorgaben.yaml b/content/combat/modules/vorgaben.yaml index c804ff5..699b16e 100644 --- a/content/combat/modules/vorgaben.yaml +++ b/content/combat/modules/vorgaben.yaml @@ -3,6 +3,7 @@ title: Kampfmechanik als Vorgabe für einen Coding-Agenten level: 2 minutes: 90 summary: Wie du eine Kampfmechanik so aufschreibst, dass ein Coding-Agent nur Code schreibt und keine Designentscheidung trifft – mit Zuständen, Parametern in Frames, Konfliktregeln, Log-Zeilen und Abnahmekriterien, die du am Log prüfst. +place: unterwegs goals: - Du erkennst Stellen in einer Vorgabe, an denen ein Coding-Agent eine Designentscheidung treffen müsste. - Du schreibst eine Vorgabe mit Ziel, Zuständen, Parametern in Frames, Konfliktregeln, Log-Zeilen, Abnahme, offenen Fragen und dem, was der Agent nicht entscheidet. @@ -19,9 +20,6 @@ resources: - title: Godot Docs · GDScript exported properties url: https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_exports.html lesson: | - > [!NOTE] Unterwegs - > Diese Lektion und ihre Übungen gehen am Handy. Die verlinkten Clips brauchen Netz, alles andere ist offline lesbar. - Du schreibst dem Coding-Agenten: „Der Trainingsgegner soll vor dem Angriff kurz aufleuchten und dann hart zuschlagen.“ Zehn Minuten später läuft die Szene. Der Gegner leuchtet 10 Frames, der Treffer hat 8 Frames Hitstop und 300 px/s Knockback (ausgedachte Zahlen für dieses Beispiel). Keine davon hast du gewählt. Der Agent hat sie eingesetzt, weil der Satz keine enthielt. Ob „kurz“ jetzt umgesetzt ist, kannst du nicht prüfen: Es gibt nichts, womit du das Ergebnis vergleichen könntest. Diese Lektion zeigt, wie du eine Kampfmechanik so aufschreibst, dass der Agent nur noch Code schreibt. Die Entscheidungen bleiben bei dir, und das Ergebnis prüfst du am Log. Das Beispiel ist eine neutrale Änderung an der Testszene aus [Testszene: Angriff mit Frame-Daten](/combat/testszene-angriff), kein Entwurf für dein Spiel. @@ -285,7 +283,7 @@ exercises: title: Die acht Teile prompt: | Bring die Teile einer Vorgabe in eine sinnvolle Reihenfolge. Jeder Teil baut auf den vorigen auf. - lang: text + lang: prose lines: | Ziel: was du testen willst Zustände und Übergänge @@ -310,7 +308,7 @@ exercises: title: Der Agent meldet „fertig“ prompt: | Der Agent hat den Telegraph in zwei Stufen gebaut. Bring die Schritte in die Reihenfolge, in der du das Ergebnis prüfst. - lang: text + lang: prose lines: | Log aus Vorgabe und Platzhaltern vorhersagen Szene mit den Platzhaltern starten