Ninja Fight ist ursprünglich ein 2D-Plattform-Kampfspiel, das in Adobe Animate / ActionScript 3 entwickelt wurde (Konzept & Umsetzung: Michael Dörflinger, Hochschule Furtwangen, Studiengang MIB, WS 2017/2018). Dieses Repo portiert das komplette, in der Abgabe unfertig gebliebene Spiel nach HTML5 Canvas + Vanilla JavaScript — lauffähig direkt im Browser, ohne Flash Player, ohne Build-Schritt, bereit für GitHub Pages.
▶ Spielen
Das Original-Archiv (Endabgabe/) enthielt:
- 35 AS3-Klassen (
Source/Classes/) — viele davon nur Rümpfe ohne Verhalten (siehe unten) - eine
ninja-fight.flamit vier fertig gestalteten Levels (Level 1–4) - Original-Sounddateien, ein Konzept-PDF und ein "Known Bugs"-PDF, in dem der Entwickler selbst dokumentiert hat, welche Systeme nicht fertig wurden
- drei praktisch identische Kopien desselben Projekts (
Build/,Source/,Projekt/) — nur eine davon wurde als Quelle verwendet
Das Spiel war laut eigener Aussage nicht fertiggestellt. Diese Portierung repariert die im Known-Bugs-Dokument aufgeführten Probleme, statt sie unverändert zu übernehmen.
| Asset | Herkunft | Verwendung |
|---|---|---|
| Level-Layouts (Level 1–4) | aus LIBRARY/Level/Level1..4.xml der FLA extrahiert (DOMSymbolInstance-Positionen) |
1:1 in assets/js/levels.js — exakt dieselbe Platzierung von Boden, Brücken, Leitern, Wasser, Feuer, Messern wie im Original |
| Sprachdaten (Englisch/Deutsch) | ExternalData/strings.json, unverändert |
assets/js/strings.js |
| Sounds | Sounds/*.mp3, unverändert |
assets/sounds/ |
| Menü-Hintergrund | eingebettetes PNG aus der FLA-Bibliothek (GUI/GUIComponent/background.png) |
assets/img/background.png |
| Charakter-Animationen (Held + 4 Gegnertypen) | echte gerenderte Frames aus einem SWF-Decompiler (JPEXS FFDec) | assets/img/sprites/{hero,blue,green,red,white}.png |
| Level-Elemente & Items (Floor, Bridge, Ladder, Knives, Flame, Sword, Shuriken, Heart) | ebenfalls aus dem Decompiler-Export | assets/img/sprites/tiles.png |
In einer früheren Fassung dieser Portierung gab es keinen Zugriff auf die eigentlichen Grafikdaten — nur auf die FLA-Quelldatei, in der die Figuren als verschachtelte Vektor-Bodyparts ohne fertig gerenderte Bilder vorlagen. Die Lösung damals: die echten Fill-Farben aus den Shape-Definitionen extrahieren und eine eigene, prozedural gezeichnete Strichfigur einfärben.
Mit einem Decompiler-Export der SWF (decompiled.zip, per JPEXS FFDec)
änderte sich das grundlegend: FFDec rendert für jede DefineSprite
(= jedes MovieClip-Symbol) jeden einzelnen Frame als fertiges PNG. Für den
Helden allein ergab das 465 Einzelbilder — jede Pose jeder Animation,
pixelgenau wie im Original. Die Frame-Grenzen pro Animationszustand
(Idle/Walk/Jump/Hit/Kick/Throw/Die/SwordHit) wurden aus den Frame-Labels in
LIBRARY/Character/Hero/Hero.xml ausgelesen (z. B. index="57" duration="51" name="Jump") — identisch für alle vier Gegnertypen.
Verarbeitungspipeline (siehe Build-Historie, nicht Teil des Repos):
- Pro Figur (Hero, Blue, Green, Red, White) und pro Zustand 8 Frames gleichmäßig aus dem jeweiligen Original-Bereich abgetastet (465 Frames pro Figur wären für ein kleines Web-Spiel unnötig groß — 8 pro Zustand reichen für eine flüssige Wirkung).
- Jeder Frame auf ein einheitliches 160×150-Fenster zugeschnitten (deckt alle beobachteten Posen sicher ab).
- Alle Frames einer Figur zu einem Sprite-Sheet zusammengefügt (8 Spalten × 8 Zeilen, eine Zeile pro Zustand) — ein Bild pro Figur statt Hunderter Einzeldateien, deutlich weniger HTTP-Requests.
- Level-Elemente und Items (die meist nur 1 Frame haben, außer Flame mit
37 Animationsframes) ebenso in ein gemeinsames
tiles.pnggepackt.
Ergebnis: 5 Charakter-Sheets (je 150–200 KB) + 1 Kachel-Sheet (15 KB) statt einer selbst gezeichneten Näherung — die Figuren sehen jetzt exakt so aus wie im Original, inklusive des tatsächlichen Kopfbands, der Maskenfarbe und der Statur.
Die "2"-Varianten aus dem Original (Idle2, Walk2, … — vermutlich
gespiegelte Versionen für die andere Blickrichtung) wurden bewusst nicht
übernommen: SwordHit existierte im Original ohnehin nur in einer Richtung,
daher wird hier durchgängig ein einfacher horizontaler Canvas-Flip
(ctx.scale(facing, 1)) für die Blickrichtung verwendet — konsistent für
alle Zustände und mit halb so vielen Bilddaten.
/
├── index.html alle Bildschirme (Menü, Spiel, Pause, …)
├── assets/
│ ├── css/style.css gemeinsames Stylesheet
│ ├── js/
│ │ ├── levels.js extrahierte Original-Leveldaten
│ │ ├── strings.js Original-Sprachdaten (EN/DE)
│ │ ├── spritedata.js Sprite-Sheet-Manifest (welcher Frame liegt wo)
│ │ ├── render.js Konstanten, Level-Aufbau, Sound, Sprite-Zeichnen
│ │ ├── entities.js Hero, Enemy, PowerUp, Projectile
│ │ ├── gamemanager.js Spiellogik, Level-Aufbau, Game-Loop
│ │ ├── ui.js Menüs, Einstellungen, Highscore
│ │ └── main.js Boot
│ ├── sounds/*.mp3 Original-Audiodateien
│ └── img/
│ ├── background.png Original-Hintergrundbild
│ └── sprites/
│ ├── hero.png, blue.png, green.png, red.png, white.png
│ │ (je 64 echte Original-Frames: 8 Zustände × 8 Posen)
│ └── tiles.png Level-Elemente & Items (inkl. animiertes Feuer)
└── README.md
Die Klassenaufteilung folgt bewusst dem Original: Hero/Enemy entsprechen
HeroController/*Controller.as, GameManager entspricht
GameManager.as + Main.as, ui.js entspricht GUIController.as +
den Menü-Klassen.
Das Original-Dokument listet 14 bekannte Probleme. Alle spielrelevanten wurden für diese Portierung behoben:
| # | Problem im Original | Fix in dieser Portierung |
|---|---|---|
| 1a | Pause-Menü: btnResume.addEventListener(CLICK, Main.resumeGame()) ruft die Funktion sofort auf, statt eine Referenz zu übergeben — Resume funktioniert nie |
ui.js: echte Funktionsreferenz () => game.resumeGame() |
| 1b | ESC-Taste zum Pausieren war auskommentiert (//pauseGame();) |
in gamemanager.js aktiv verdrahtet |
| 1c | Shuriken-Wurfanimation lief, aber es wurde nie ein Projektil erzeugt | Projectile-Klasse in entities.js, die sich durch den Raum bewegt und bei Treffer/Bildschirmrand verschwindet |
| 1d | Leiter-Klettern war nicht implementiert (keine Prüfung, ob der Spieler gerade eine Leiter berührt) | Hero.update(): Leiterzone wird geprüft, W/S bewegen die Figur vertikal |
| 1e | Sprung funktionierte nicht zuverlässig (Bodenkollision widersprach der Sprungbewegung) | vollständig neue, geschwindigkeitsbasierte Sprungphysik (siehe unten) |
| 2 | Keine Schadenskollision zwischen Held und Gegnern (nur Kommentare im Code, nie umgesetzt) | echte Angriffs-Hitboxen für Hit/Kick/Schwert beim Helden, Kontakt-/Fernangriffe bei Gegnern |
| 7 | Highscore-Liste ließ sich nach dem Laden nicht scrollen | entfällt durch natives HTML-Scrolling (overflow-y: auto) |
| 12 | Gegner-Verhalten war nie implementiert (*Controller.as sind leere Rümpfe) — keine Bewegung, keine Landung auf Plattformen |
einfache Patrouillen-KI mit derselben Schwerkraft-/Landungs-Logik wie beim Helden, plus Angriffe je nach Gegnertyp |
| 14 | Spielabsturz beim Tod durch Feuer, weil das Aufräumen mitten in der Kollisionsschleife des Helden selbst passierte | Aufräumen wird per setTimeout() auf den nächsten Frame verschoben (siehe onHeroDeath()) |
Zusätzlich behoben, weil beim Portieren aufgefallen: In
AnimationController.as lautete die Bedingung zum Sperren einer laufenden
Animation currentAnimation != "Jump" || currentAnimation != "Jump2" || ...
— eine Kette von !=-Vergleichen mit || ist immer wahr (ein String kann
nicht gleichzeitig zwei verschiedene Werte NICHT sein), wodurch die Sperre
nie griff. In dieser Portierung sperrt Hero.setState() korrekt: eine
laufende Angriffs-/Sprunganimation kann nicht durch Bewegung unterbrochen
werden (mit Tests verifiziert, siehe unten).
Das Spiel spielt tagsüber vor einer freundlichen Wald-/Berg-Kulisse — nicht
nachts, wie eine frühere Fassung dieser Portierung versehentlich annahm.
Laut den Original-Leveldaten (Level1..4.xml) ist die
GUI/GUIComponent/Background-Grafik (dasselbe helle Hintergrundbild wie im
Hauptmenü) das erste Element in jedem einzelnen Level. Mit den jetzt
verfügbaren Original-Sprites ist das ohnehin hinfällig — Boden, Wasser,
Brücke, Leiter und Gefahren sehen exakt wie im Original aus, nicht nur
farblich angenähert.
| Problem | Ursache | Fix |
|---|---|---|
| Ganzes Spielfeld wirkte abgedunkelt, wie durch ein Overlay | Die Basis-Regel .screen setzte einen dunklen Verlauf als Hintergrund für alle Bildschirme, auch #screen-game — der während des Spiels direkt über dem Canvas liegt |
#screen-game { background: none; } mit hoher ID-Spezifität, damit es nicht mit der .with-bg-Regel der Menüs kollidiert |
| Herz-Pickup wirkte überdimensioniert | PowerUp-Icons wurden in Original-Sprite-Größe (35×30px) statt herunterskaliert gezeichnet | einheitlicher Skalierungsfaktor 0.55 für Heart/Sword/Shuriken-Pickups |
| Flammen saßen zu tief/wirkten zu klein | Flame.y in den Original-Leveldaten entspricht der Bodenhöhe (Basis der Flamme), nicht ihrer Oberkante — wurde als Top-Left-Position missverstanden |
Flamme wird jetzt mit der Unterkante an y verankert (wächst nach oben) und um Faktor 1.3 vergrößert |
| Keine erkennbare Kletter-Animation auf der Leiter | Das Original hat keine eigene Climb-Animation (laut KnownBugs war das Klettern nie vollständig fertig) | ersatzweise werden die Jump-Frames in Dauerschleife verwendet — optisch die am ehesten passende vorhandene Pose |
| Gegner wirkten wie eingefroren | Patrouillenradius war mit ±70px winzig, Spawn-Positionen konnten sich gegenseitig überlappen | Patrouillenradius auf ±220px erhöht, Spawn-Positionen gleichmäßig über die Levelbreite verteilt, außerdem gelegentliches Klettern und Springen während der Patrouille ergänzt |
| Wunsch/Problem | Umsetzung |
|---|---|
| Shuriken-Anzahl im HUD sichtbar machen | neues HUD-Feld #hud-weapons zeigt Shuriken-Anzahl (✦) und ob ein Schwert getragen wird (🗡) |
| Lebensanzeige über Gegnern | drawHealthBar() in render.js — schmaler grün/orange/rot abnehmender Balken über jedem Gegnerkopf |
| Leiter: am oberen/unteren Ende nicht normal weiterlaufen können | Kletterlogik komplett neu geschrieben: Klettern greift nur, solange ausschließlich hoch/runter gedrückt wird — sobald links/rechts gedrückt wird, wird die Leiter sofort verlassen und normale Bewegung/Schwerkraft greift wieder |
| Keine Kletter-Sprites | Das Original hat keine eigene Climb-Animation (nie fertiggestellt) — ersatzweise laufen die Jump-Frames in Dauerschleife während des Kletterns |
| Schwert nicht sichtbar, wenn getragen (nur bei Angriff) | Im Original zeigt nur die SwordHit-Animation selbst das Schwert. Für Idle/Walk/Jump wird jetzt ein kleines Schwert-Sprite schräg auf dem Rücken ergänzt (showSheathed in drawNinja()), solange hasSword gesetzt ist und keine Angriffsanimation läuft — gilt für Helden und Gegner gleichermaßen |
| Gegner sollen Items einsammeln können | PowerUp.update() prüft jetzt alle Charaktere (Held und alle lebenden Gegner) auf Kollision, nicht mehr nur den Helden — wer zuerst da ist, bekommt das Item. Gegner haben dafür eine eigene collectPowerUp()-Methode (Heart heilt, Sword/Shuriken schalten die jeweilige Fähigkeit frei) |
| Wunsch/Problem | Umsetzung |
|---|---|
| Lebensbalken saß im Kopf statt darüber | Sprite-Anchor genauer vermessen (Kopf-Oberkante liegt bei y − 61px, nicht bei der Trefferbox-Höhe) — Balken jetzt bei y − 70px, klar über dem Kopf |
| Level endete nicht früher, wenn kein Gegner mehr da war | Zwei Ursachen behoben: (1) Gegner, die von der Karte fallen, gelten jetzt als besiegt (onEnemyKilled() wird ausgelöst); (2) zusätzliche Sicherheit — fällt ein Gegner länger als 3s ununterbrochen (z. B. in eine nicht erreichbare Vertiefung), gilt er ebenfalls als besiegt |
| Schwert/Shuriken gingen bei Levelwechsel verloren | Echter Bug gefunden: cleanUpLevel() setzte this.hero = null, bevor der Waffenstatus für die Übernahme ins nächste Level ausgelesen wurde — die Werte waren dadurch immer schon weg. Jetzt wird der Status vor dem Aufräumen gesichert |
| Nur 4 Level | 6 neue Level ergänzt (jetzt 10 insgesamt). Level 5–10 sind neu von Hand entworfen (keine Original-FLA-Daten dafür vorhanden), mit Feuer, Leitern, Stacheln und Wassergräben, jeweils vollständig begehbarem Boden (automatisiert geprüft). Ab Level 5 sind die Gegnertypen gemischt (zufällig aus Blue/Green/Red/White gewählt), Level 1–4 bleiben bei ihrem Original-Einzeltyp. Gegneranzahl pro Level unterschiedlich: 5, 6, 5, 7, 6, 8 |
| Wunsch/Problem | Umsetzung |
|---|---|
| Nach Level 10 kam Game Over statt einer Sieg-Anzeige | neuer #screen-victory-Bildschirm ("🏆 You Won!") mit eigenem Highscore-Eintrag, ausgelöst über GameManager.winGame() statt endGame(), wenn alle 10 Level ohne Tod abgeschlossen wurden |
| Gegner fielen zu oft von Plattformen | Enemy.hasSupportAhead() prüft jetzt vor jeder Bewegung, ob vor der Figur noch Boden oder eine Leiter ist — ist das nicht der Fall, wird umgedreht statt weiterzulaufen. Zusätzlich: der zufällige Sprung wird nur noch ausgelöst, wenn auch vor dem Sprung Boden erkannt wird |
| Dabei gefunden: Leiter-Kollisionsbox reichte weit unter das eigentliche Bodenniveau | mergeLadderColumns() addierte fälschlich sowohl die Kachelhöhe als auch eine komplette zusätzliche Kachelhöhe auf das untere Ende — die Leiter reichte dadurch bis zu 48px unter den Boden. Wer die Leiter am unteren Ende verließ, konnte dadurch unter die eigentliche Landefläche fallen. Behoben; alle 10 Level zusätzlich mit einem eigenen Test über 60 simulierte Sekunden pro Level verifiziert (0% Sturz-Tode) |
| Schwert war dauerhaft, sollte zeitlich begrenzt sein | 5 Sekunden ab Aufnahme (SWORD_PICKUP_DURATION), danach automatisch wieder verloren — gilt für Held und gestohlene Schwerter bei Gegnern. Gegner mit angeborener Schwertfähigkeit (Red/White) behalten sie dauerhaft, das ist ihre Grundausstattung, keine Zeitbegrenzung |
| Alle Angriffe machten gleich viel Schaden | gemeinsame Schadenstabelle: Schlag = 1, Tritt = 2, Shuriken = 5, Schwert = 10 — gilt symmetrisch für Held und Gegner |
| Alle Gegner hatten gleich viel HP (3, "nach 3 Tritten KO") | HP jetzt an den Ursprungslevel gekoppelt, nicht an das aktuelle Level: Blue (Level 1) = 10 HP, Green (Level 2) = 20 HP, Red (Level 3) = 30 HP, White (Level 4) = 50 HP — bleibt auch in den gemischten Leveln 5–10 so |
| Gegner aus Level 1 konnten nur "Hit" | alle Gegner wählen jetzt zufällig zwischen Schlag und Tritt im Nahkampf; Shuriken/Schwert bleiben exklusiv an die jeweilige Spezialfähigkeit gebunden (Blue: keine, Green: Shuriken, Red: Schwert, White: beides) |
| Wunsch | Umsetzung |
|---|---|
| Schwert 30s statt 5s haltbar, Timer läuft erst ab dem Aufheben | SWORD_PICKUP_DURATION von 5 auf 30 erhöht. Ein am Boden liegendes Schwert hatte ohnehin nie ein Ablaufdatum — der Timer startet ausschließlich beim tatsächlichen Aufheben |
| Gegner ohne Spezialfähigkeit sollen gestohlene Items trotzdem benutzen können | war bereits korrekt umgesetzt (Angriffslogik prüft die veränderliche hasSword/hasShuriken-Eigenschaft, nicht die feste def.canSword/canShuriken) — mit eigenem Test verifiziert |
| Lebenspunkte des Helden pro Level unterschiedlich, aufaddierend statt zurückgesetzt | this.lifeEnergy += 10 * levelNum statt = 10 * levelNum — Level 1 gibt +10, Level 2 +20, … Level 10 +100, jeweils zum Rest aus dem vorigen Level addiert. Bei einem echten Neustart (startGame()) wird auf 0 zurückgesetzt, damit die Formel wieder sauber bei 10 beginnt |
| Schwert/Shuriken müssen ins nächste Level übertragen werden | war für hasSword/hasShuriken/shurikenCount bereits umgesetzt — ergänzt um den verbleibenden swordTimer (der 30-Sekunden-Countdown läuft jetzt nahtlos über Levelgrenzen hinweg weiter, statt beim Levelwechsel neu zu starten) |
| Wunsch | Umsetzung |
|---|---|
| Herz heilt auch Gegner | war bereits umgesetzt (Enemy.collectPowerUp("Heart")) — mit Test bestätigt |
| Feuer/Stacheln verletzen auch Gegner | neue Enemy.checkHazards(), spiegelbildlich zu Hero.checkHazards(): Feuer zieht 1 HP pro Kontakt (mit kurzer Abklingzeit), Stacheln 5 HP — Gegner sterben dadurch nicht sofort, sondern verlieren nur Lebenspunkte, wie gewünscht |
| Friendly Fire zwischen Gegnern (Schlag/Tritt/Schwert/Shuriken) | Enemy.hitNearbyEnemies() prüft bei jedem Nahkampftreffer zusätzlich andere Gegner im Wirkungsbereich; Projectile wurde umgebaut, um die tatsächliche Werfer-Instanz zu kennen (statt nur "hero"/"enemy" als Text) und trifft jetzt jeden außer dem Werfer selbst — ein Shuriken kann dadurch versehentlich einen anderen Gegner treffen |
| Zeit abgelaufen + noch Gegner übrig = Niederlage | vorher wurde ein Zeitablauf immer als "Level geschafft, weiter" gewertet, egal wie viele Gegner noch lebten. Jetzt: sind bei Zeit-Ablauf noch Gegner am Leben, ist das Spiel vorbei (Game Over) statt automatisch ins nächste Level zu wechseln |
| Problem | Fix |
|---|---|
| Beim Klettern rutschte man herunter, sobald man hoch/runter losließ, obwohl man noch mitten auf der Leiter stand | Derselbe Fehler wie im Begleit-Tutorial (Kapitel 9): die Schwerkraft wurde nur aufgehoben, solange aktiv hoch/runter gedrückt wurde. Jetzt gilt: solange man sich in der Leiterzone befindet (und nicht seitlich aussteigt), ist die Schwerkraft grundsätzlich aufgehoben — man hängt an einer Sprosse fest, auch ohne eine Taste zu drücken, und bewegt sich nur, wenn man aktiv hoch oder runter drückt. Mit eigenem Test verifiziert (hero.vy bleibt 0, Position bleibt exakt gleich, solange man in der Zone ist und keine Taste drückt) |
- Server-Highscore →
localStorage. Das Original schickte Highscores perURLLoaderan einen PHP-Server auf dem Hochschul-Webspace (saveScore.php/highscore.php). GitHub Pages kann kein PHP ausführen — die Highscore-Liste wird deshalb lokal im Browser gespeichert (localStorage). Das Prinzip (Name, Punkte, Level speichern und sortiert anzeigen) bleibt identisch, nur ist die Liste jetzt pro Gerät statt global. - Sprung-/Bewegungsphysik neu geschrieben. Statt die fehlerhafte
Original-Physik (
deltaY += 0.125, kollidierende Prüfschleifen) zu reparieren, wurde eine saubere, geschwindigkeitsbasierte Physik verwendet (konstante Erdanziehung, Sprunggeschwindigkeit, Landungserkennung über Plattform-Kacheln) — dasselbe Muster wie in den beiden vorigen Repos dieser Reihe.
Da dieses Spiel mit echten Fehlerbehebungen wirbt, wurden die kritischen Systeme mit einem headless Node-Test verifiziert (Sprung, Leiter, Schuriken-Treffer, Nahkampf-Treffer, Pause/Resume-Einfrieren, Level-Fortschritt über alle 4 Level, Tod ohne Absturz, Highscore-Speicherung). Alle Tests laufen grün. Der Testcode ist nicht Teil des Repos (reines Entwicklungswerkzeug), aber die Ergebnisse sind in den Commit-Notizen nachvollziehbar.
| Taste | Aktion |
|---|---|
W/↑, S/↓ |
Leiter hoch/runter |
A/←, D/→ |
Laufen |
Leertaste |
Springen |
R |
Schlagen |
F |
Treten |
E |
Schwert (wenn eingesammelt) |
Q |
Shuriken (wenn eingesammelt) |
ESC |
Pause |
python3 -m http.server 8000
# dann: http://localhost:8000(Ein lokaler Server ist nicht strikt nötig, da diese Version ohne
fetch()-Aufrufe auskommt — funktioniert auch direkt per file://.)
- Repo auf GitHub anlegen (z. B.
NinjaFight) und diesen gesamten Ordner hineinpushen —index.htmlmuss im Root-Verzeichnis liegen. - Im Repo zu Settings → Pages gehen.
- Unter "Build and deployment" → Source "GitHub Actions" auswählen.
- GitHub schlägt daraufhin die Workflow-Vorlage "Static HTML" vor —
diese auswählen und committen (erzeugt automatisch
.github/workflows/static.yml). - Warten, bis der Workflow unter Actions grün durchläuft (Build + Deploy).
- Die Seite ist danach unter
https://<dein-username>.github.io/<repo-name>/erreichbar.
Falls der Deploy-Schritt mit "Deployment failed, try again later" fehlschlägt, obwohl der Build erfolgreich war: einmal Re-run all jobs im fehlgeschlagenen Workflow-Run ausführen — das behebt laut den GitHub-eigenen Diskussionen dazu die meisten Fälle, da es sich meist um ein kurzzeitiges Problem auf GitHubs Seite handelt, nicht um einen Fehler in eurem Repo.
Original-Konzept und ActionScript-3-Umsetzung: Michael Dörflinger, Hochschule Furtwangen (Studiengang MIB), Vorlesung "Spieleentwicklung 2D", WS 2017/2018. Diese Portierung ersetzt Animate/Flash vollständig durch HTML5 Canvas + Vanilla JS und behebt die im Original dokumentierten Fehler.