Design wechseln

Cocos Mini-Spiel: Charakterbewegung und Angriff – von Nodes bis Animation

Easton editorial illustration: one game character moving through idle, move, attack, and return poses above a compact node rig

Ihr Charakter steht in der Bildschirmmitte, Sie drücken die Richtungstaste – nichts passiert. Im Code liegen Bewegungslogik, Animationswiedergabe und Zustandsprüfung alle in einer update-Funktion. Sie ändern die Animation und vergessen den Zustand, ändern den Zustand und vergessen die Geschwindigkeit – am Ende wissen Sie selbst nicht mehr, was der Charakter gerade tun soll.

Node-Trennung klingt abstrakt, bedeutet aber schlicht: Bewegung und Animation getrennt steuern – der Player-Node übernimmt horizontale Verschiebung, der Body-Node vertikale Sprünge und Angriffs-Animationen, beide laufen unabhängig voneinander. Dieser Artikel führt Sie von der Node-Architektur über Animations-Konfiguration und State-Machine bis zur Eingabesteuerung – mit vollständigem Code für einen Side-Scrolling-Kampfcharakter.

Charakter-Node-Architektur – horizontale Bewegung + vertikale Animation

Im offiziellen Cocos Creator 3.7-Tutorial gibt es ein leicht übersehenes Design: Player- und Body-Nodes sind getrennt. Viele (mich eingeschlossen) halten das zunächst für unnötig – ein Node für Bewegung, Animation und Kollisionserkennung, geht das nicht auch?

Das Problem: Animation und Bewegung haben unterschiedliche Rhythmen. Stellen Sie sich einen Sprung vor – horizontal gleichmäßig vorwärts, vertikal eine Parabel. In einem einzigen Node müsste update horizontalen Versatz und Sprunghöhe gleichzeitig berechnen – der Code wird schnell chaotisch. Mit Angriffs-Animation, Treffer-Effekten und Tod-Rotation/Skalierung gerät die Logik eines einzelnen Nodes aus dem Ruder.

Vorteil der Trennung: Bewegung ist Bewegung, Animation ist Animation. Der Player-Node verwaltet nur die x-Koordinate, der Body-Node nur y-Sprung und Animations-Frames – beide stören sich nicht gegenseitig.

Node-Struktur:

Player (Root-Node)
  ├── PlayerControl.ts (Bewegungslogik)
  └── Body (Child-Node)
       ├── Sprite (Charakterbild)
       └── Animation-Komponente

Code-Beispiel – Player-Node-Bewegung:

// PlayerControl.ts - Player-Node-Skript
@property(CCFloat)
speed: number = 200; // Bewegungsgeschwindigkeit

private moveDir: Vec2 = new Vec2(0, 0);

update(dt: number) {
  if (this.moveDir.mag() > 0.5) {
    const vx = this.moveDir.x * this.speed;
    this.node.x += vx * dt; // nur horizontale Position
  }
}

// Richtung von außen setzen
setMoveDir(dir: Vec2) {
  this.moveDir = dir;
}

Code-Beispiel – Body-Node-Animation:

// BodyControl.ts - Body-Node-Skript
@property(Animation)
animation: Animation = null;

jump(height: number = 100) {
  // Sprung: vertikale Verschiebung + jump-Animation
  this.node.runAction(cc.jumpBy(1.0, 0, 0, height, 1));
  this.animation.play('jump');
}

attack() {
  this.animation.play('attack');
  // nach Angriff automatisch zu idle
  this.scheduleOnce(() => {
    this.animation.play('idle');
  }, 0.5);
}

Bewegungslogik liegt am Player-Node, Animationslogik am Body-Node. Sprunghöhe ändern, ohne Bewegungscode anzufassen; Geschwindigkeit anpassen, ohne im Animations-Skript zu suchen – deutlich einfacher zu warten.

Animations-Komponente konfigurieren – Animation + AnimationClip

Die Animation-Komponente ist in Cocos Creator eingebaut, doch einige Eigenschaften werden leicht übersehen: defaultClip, das clips-Array und playOnLoad.

Kern-Eigenschaften der Animation-Komponente:

  • defaultClip: Standard-Animation (meist idle)
  • clips: Animations-Ressourcen (idle, walk, attack usw.)
  • currentClip: aktuell laufende Animation (Laufzeit)

Konfiguration in fünf Schritten:

  1. Animation-Komponente am Body-Node hinzufügen
  2. AnimationClip-Ressourcen ins clips-Array ziehen
  3. defaultClip auf idle setzen
  4. playOnLoad aktivieren (Charakter startet im Stand)
  5. Im Code per animation.play('clipName') wechseln

Code-Beispiel – Animations-Konfiguration:

// PlayerAnimation.ts - Animations-Steuerung
@property(Animation)
animation: Animation = null;

@property([AnimationClip])
clips: AnimationClip[] = []; // idle, walk, attack, jump

onLoad() {
  this.animation.clips = this.clips;
  this.animation.defaultClip = this.clips[0]; // Standard: idle
  this.animation.play();
}

playIdle() { this.animation.play('idle'); }
playMove() { this.animation.play('move'); }
playAttack() {
  this.animation.play('attack');
  this.scheduleOnce(() => { this.playIdle(); }, 0.5);
}

Wichtiges Detail: Beim Animationswechsel zuerst stop(), dann play() – besonders bei einmaligen Clips wie attack. Sonst kann beim Angriff während des Gehens die walk-Animation mit attack konkurrieren – der Charakter wirkt, als würde er gleichzeitig schwingen und rutschen.

Korrekter Animationswechsel:

switchAnimation(name: string) {
  if (this.animation.currentClip?.name !== name) {
    this.animation.stop();
    this.animation.play(name);
  }
}

Als Nächstes die State-Machine – damit Animationswechsel regelbasiert statt als verstreute if (speed>0) play('move')-Prüfungen laufen.

Animations-State-Machine – idle → move → attack → idle

Kernidee: Der Charakter ist immer genau in einem Zustand (idle, move, attack), Übergänge folgen klaren Bedingungen – nicht willkürlich.

Anfangs wirkte mir das überdesignt – play('attack') reicht doch? Mit Sprung-, Verletzungs- und Tod-Zuständen landen die Prüfungen überall: im Bewegungs-, Animations- und Eingabe-Skript. Eine Änderung an einer Stelle, zwei andere vergessen – und das Verhalten bricht.

State-Machine-Ablauf:

[Entry] → [idle]
          ↓ speed>0
         [move]
          ↓ attackKey
         [attack] → [Exit] → [idle]

Drei Zustände: idle (Stand), move (Bewegung), attack (Angriff). Übergänge:

  • idle → move: Geschwindigkeit > 0
  • move → idle: Geschwindigkeit = 0
  • beliebig → attack: Angriffstaste
  • attack → idle: Angriffs-Animation beendet

Code-Beispiel – State-Machine:

// PlayerStateMachine.ts
enum State {
  Idle = 'idle',
  Move = 'move',
  Attack = 'attack'
}

@property(Animation)
animation: Animation = null;

private currentState: State = State.Idle;
private isMoving: boolean = false;
private attackKeyPressed: boolean = false;

switchState(newState: State) {
  if (this.currentState === newState) return; // Doppelwechsel vermeiden
  this.animation.stop();
  this.animation.play(newState);
  this.currentState = newState;
}

update(dt: number) {
  // Bewegungszustand
  if (this.isMoving && this.currentState !== State.Move) {
    this.switchState(State.Move);
  } else if (!this.isMoving && this.currentState !== State.Idle) {
    this.switchState(State.Idle);
  }

  // Angriff (Vorrang vor Bewegung)
  if (this.attackKeyPressed && this.currentState !== State.Attack) {
    this.switchState(State.Attack);
    this.scheduleOnce(() => { this.switchState(State.Idle); }, 0.5);
  }
}

setMoving(value: boolean) { this.isMoving = value; }

triggerAttack() {
  this.attackKeyPressed = true;
  this.scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1);
}

Wichtige Details:

  • Zustands-Priorität: Angriff vor Bewegung (keine Bewegung während Angriff)
  • Doppelwechsel vermeiden: if (currentState === newState) return
  • Timer-Callback: Nach Angriff automatisch zu idle, nicht auf Tastenloslassen warten

Die State-Machine bündelt Animationslogik an einer Stelle. Neue Zustände wie Sprung, Verletzung oder Tod: Enum erweitern, Übergänge ergänzen – deutlich skalierbarer als verstreute if-Abfragen.

Eingabesteuerung im Vergleich – Tastatur vs. Touch vs. Virtual Joystick

Drei Ansätze für unterschiedliche Szenarien – die falsche Wahl verschlechtert das Spielgefühl spürbar.

LösungEinsatzVorteileNachteileKomplexität
TastaturPC, DebugEinfach, schnellNicht auf dem HandyNiedrig
TouchVollbild-Spiele (Flug, Parkour)Natürlich, kein UI-OverlayUngenau, FehlberührungenMittel
Virtual JoystickARPG, KampfPräzise, 360°Belegt BildschirmflächeHoch

Tastatur-Eingabe (PC-Standard)

Für PC-Spiele oder schnelles Debuggen ist die Tastatur die einfachste Lösung.

Code-Beispiel – Tastatur-Eingabe:

// KeyboardInput.ts
private moveDir: Vec2 = new Vec2(0, 0);

onLoad() {
  input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
  input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
}

onKeyDown(event: EventKeyboard) {
  switch (event.keyCode) {
    case KeyCode.KEY_A:
    case KeyCode.ARROW_LEFT:
      this.moveDir.x = -1; break;
    case KeyCode.KEY_D:
    case KeyCode.ARROW_RIGHT:
      this.moveDir.x = 1; break;
    case KeyCode.KEY_J:
      this.playerStateMachine.triggerAttack(); break;
  }
}

onKeyUp(event: EventKeyboard) {
  switch (event.keyCode) {
    case KeyCode.KEY_A:
    case KeyCode.ARROW_LEFT:
    case KeyCode.KEY_D:
    case KeyCode.ARROW_RIGHT:
      this.moveDir.x = 0; break;
  }
}

update(dt: number) {
  this.playerControl.setMoveDir(this.moveDir);
  this.playerStateMachine.setMoving(this.moveDir.mag() > 0.5);
}

Details:

  • Tastenbelegung: A/D und Pfeiltasten parallel
  • Angriffstaste: J (üblich in Kampfspielen)
  • Zustandssync: moveDir und State-Machine pro Frame aktualisieren

Virtual Joystick (Mobile-Standard)

Für Handy-Side-Scroller und ARPG mit präziser Richtung ist der Virtual Joystick fast Pflicht.

Implementierungs-Punkte:

  • Grenzenbegrenzung: Griff darf den Hintergrundkreis nicht verlassen
  • Normalisierung: Einheitsvektor zurückgeben
  • Touch-Ende: Griff kehrt zur Mitte zurück

Code-Beispiel – Virtual Joystick:

// Joystick.ts
@property(Node)
joystickBg: Node = null;

@property(Node)
joystickHandle: Node = null;

@property(CCFloat)
radius: number = 100;

private moveDir: Vec2 = new Vec2(0, 0);

onLoad() {
  input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
  input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
}

onTouchMove(event: EventTouch) {
  const touchPos = event.getUILocation();
  const localPos = this.joystickBg.inverseTransformPoint(
    v3(), v3(touchPos.x, touchPos.y, 0)
  );

  const distance = localPos.length();
  if (distance > this.radius) {
    localPos.x = this.radius * localPos.x / distance;
    localPos.y = this.radius * localPos.y / distance;
  }

  this.joystickHandle.setPosition(localPos);
  this.moveDir = v2(localPos.x, localPos.y).normalize();
}

onTouchEnd() {
  this.joystickHandle.setPosition(Vec3.ZERO);
  this.moveDir = v2(0, 0);
}

getMoveDir(): Vec2 { return this.moveDir; }

Details:

  • Koordinaten: inverseTransformPoint für lokale Node-Koordinaten
  • Radius: if (distance > radius) hält den Griff im Kreis
  • Normalisierung: normalize() liefert Einheitsvektor für Geschwindigkeit × Richtung

Für Flug- oder Parkour-Spiele ohne präzise Richtung reicht Vollbild-Touch – die Logik unterscheidet sich vom Joystick und muss zum Spieltyp passen.

Praxis – vollständige Side-Scrolling-Kampfcharakter-Steuerung

Node-Architektur, Animation, State-Machine und Eingabe zu einer lauffähigen Lösung zusammenführen.

Code-Struktur:

Player (Root-Node)
  ├── PlayerControl.ts (Bewegung)
  ├── PlayerStateMachine.ts (State-Machine)
  └── KeyboardInput.ts (Tastatur)

Body (Child-Node)
  ├── Sprite (Charakterbild)
  ├── Animation-Komponente
  └ AttackEffect (Angriffs-Effekt, optional)

Vollständiger Code – PlayerControl.ts:

import { _decorator, Component, Node, Vec2, CCFloat } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('PlayerControl')
export class PlayerControl extends Component {
  @property(CCFloat)
  speed: number = 200;

  private moveDir: Vec2 = new Vec2(0, 0);

  update(dt: number) {
    if (this.moveDir.mag() > 0.5) {
      const vx = this.moveDir.x * this.speed;
      this.node.x += vx * dt;
    }
  }

  setMoveDir(dir: Vec2) { this.moveDir = dir; }
}

Vollständiger Code – PlayerStateMachine.ts:

import { _decorator, Component, Animation } from 'cc';
const { ccclass, property } = _decorator;

enum State {
  Idle = 'idle',
  Move = 'move',
  Attack = 'attack'
}

@ccclass('PlayerStateMachine')
export class PlayerStateMachine extends Component {
  @property(Animation)
  animation: Animation = null;

  private currentState: State = State.Idle;
  private isMoving: boolean = false;
  private attackKeyPressed: boolean = false;

  onLoad() { this.animation.play('idle'); }

  switchState(newState: State) {
    if (this.currentState === newState) return;
    this.animation.stop();
    this.animation.play(newState);
    this.currentState = newState;
  }

  update(dt: number) {
    if (this.isMoving && this.currentState !== State.Move) {
      this.switchState(State.Move);
    } else if (!this.isMoving && this.currentState !== State.Idle) {
      this.switchState(State.Idle);
    }

    if (this.attackKeyPressed && this.currentState !== State.Attack) {
      this.switchState(State.Attack);
      this.scheduleOnce(() => { this.switchState(State.Idle); }, 0.5);
    }
  }

  setMoving(value: boolean) { this.isMoving = value; }
  triggerAttack() {
    this.attackKeyPressed = true;
    this.scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1);
  }
}

Vollständiger Code – KeyboardInput.ts:

import { _decorator, Component, input, Input, EventKeyboard, KeyCode, Vec2 } from 'cc';
import { PlayerControl } from './PlayerControl';
import { PlayerStateMachine } from './PlayerStateMachine';
const { ccclass, property } = _decorator;

@ccclass('KeyboardInput')
export class KeyboardInput extends Component {
  @property(PlayerControl)
  playerControl: PlayerControl = null;

  @property(PlayerStateMachine)
  stateMachine: PlayerStateMachine = null;

  private moveDir: Vec2 = new Vec2(0, 0);

  onLoad() {
    input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
  }

  onDestroy() {
    input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
    input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
  }

  onKeyDown(event: EventKeyboard) {
    switch (event.keyCode) {
      case KeyCode.KEY_A:
      case KeyCode.ARROW_LEFT:
        this.moveDir.x = -1; break;
      case KeyCode.KEY_D:
      case KeyCode.ARROW_RIGHT:
        this.moveDir.x = 1; break;
      case KeyCode.KEY_J:
        this.stateMachine.triggerAttack(); break;
    }
  }

  onKeyUp(event: EventKeyboard) {
    switch (event.keyCode) {
      case KeyCode.KEY_A:
      case KeyCode.ARROW_LEFT:
      case KeyCode.KEY_D:
      case KeyCode.ARROW_RIGHT:
        this.moveDir.x = 0; break;
    }
  }

  update(dt: number) {
    this.playerControl.setMoveDir(this.moveDir);
    this.stateMachine.setMoving(this.moveDir.mag() > 0.5);
  }
}

Erwartetes Verhalten:

  • Spielstart → idle-Animation
  • A/D oder Pfeiltasten → Bewegung + walk-Animation
  • J → Angriff + attack-Animation → nach 0,5 s idle
  • Richtungstaste loslassen → Stopp + idle

Typische Probleme und Lösungen:

  1. Animations-Ruckeln: AnimationClip-Framerate prüfen (empfohlen 12–24 fps)
  2. Hängender Zustand: Nach Angriff kein idle – Timer-Callback prüfen
  3. Eingabekonflikt: Bewegung während Angriff – Angriffs-Priorität vor Bewegungsprüfung setzen

Nach der Integration: Bewegung in PlayerControl, Animation in StateMachine, Eingabe in KeyboardInput – drei Skripte, klare Schnittstellen, keine gegenseitige Störung.

Zusammenfassung

Drei Schichten bilden das Kern-Design:

  1. Node-Trennung: Player für Bewegung, Body für Animation
  2. State-Machine: idle, move, attack mit zentralen Übergangsbedingungen
  3. Eingabe: Tastatur am PC, Virtual Joystick auf Mobile – passend zum Spieltyp

Vorteil: Änderungen bleiben lokal. Geschwindigkeit anpassen, ohne Animationscode zu berühren; Angriffs-Effekt hinzufügen, ohne Bewegungs-Skript zu ändern; Joystick statt Tastatur – State-Machine bleibt gleich.

Als Nächstes können Sie Teil 4 der Serie „Mini-Spiel-State-Machine-Design“ nutzen und komplexere Zustände wie Sprung, Verletzung, Tod oder Skills ergänzen. Die Charakter-State-Machine ist ein Submodul der Spiel-State-Machine – das Prinzip bleibt: Zustände definieren, Übergänge festlegen, Wechsel zentral steuern.

Cocos Creator: Charakterbewegung und Angriff implementieren

Von der Node-Architektur bis zur Eingabesteuerung – vollständige Side-Scrolling-Kampfcharakter-Steuerung

⏱️ Estimated time: 45 min

  1. 1

    Step 1: Node-Architektur entwerfen

    Player-Root-Node mit Bewegungs-Skript erstellen, Body-Child-Node mit Animation-Komponente und Sprite. Player steuert nur x-Koordinate, Body übernimmt y-Sprung und Animations-Frames.
  2. 2

    Step 2: Animation-Komponente konfigurieren

    Am Body-Node Animation-Komponente hinzufügen, AnimationClip-Ressourcen für idle, walk, attack einbinden. defaultClip auf idle setzen, playOnLoad aktivieren. Im Code per animation.play('clipName') wechseln.
  3. 3

    Step 3: State-Machine implementieren

    State-Enum (Idle, Move, Attack) definieren, switchState-Methode implementieren. In update anhand von isMoving und attackKeyPressed Zustände wechseln; Angriff per scheduleOnce nach 0,5 s zurück zu idle.
  4. 4

    Step 4: Eingabesteuerung anbinden

    KeyboardInput-Skript für KEY_DOWN und KEY_UP erstellen. A/D und Pfeiltasten für Richtung, J für Angriff. moveDir in update pro Frame an PlayerControl und StateMachine übergeben.
  5. 5

    Step 5: Testen und optimieren

    Spiel starten und prüfen: idle-Animation läuft, Richtungstasten bewegen Charakter mit walk-Animation, Angriffstaste löst attack aus und kehrt nach 0,5 s zu idle zurück – flüssige Übergänge ohne Ruckeln.

FAQ

Warum Player und Body als zwei Nodes trennen?
Bewegung und Animation haben unterschiedliche Rhythmen. Beim Sprung bewegt sich der Charakter horizontal gleichmäßig, vertikal in einer Parabel. In einem einzigen update müssten Sie Verschiebung und Sprunghöhe gleichzeitig berechnen – der Code wird schnell unübersichtlich. Getrennt verwaltet Player nur x, Body nur y und Animations-Frames – klare, wartbare Logik.
Charakter schwingt das Schwert und rutscht gleichzeitig – wie beheben?
Ursache: Animation wird ohne vorheriges stop() gewechselt. Vor dem Wechsel prüfen, ob die aktuelle Animation von der Ziel-Animation abweicht – wenn ja, erst stop(), dann play(). Außerdem hat der Angriffs-Zustand Vorrang vor Bewegung; Eingabe während des Angriffs deaktivieren.
Nach dem Angriff bleibt der Charakter im attack-Frame, kehrt nicht zu idle zurück?
Timer-Callback prüfen. Bei triggerAttack attackKeyPressed auf true setzen, dann scheduleOnce(() => { this.attackKeyPressed = false; }, 0.1), damit das Angriffssignal nicht mehrfach auslöst. Nach der Angriffs-Animation per scheduleOnce(() => { this.switchState(State.Idle); }, 0.5) automatisch zu idle wechseln.
Welche Eingabe für PC und Mobile?
PC: Tastatur (einfach, gut zum Debuggen). Mobile je nach Spieltyp: Side-Scrolling-Kampf und ARPG mit präziser Richtung → Virtual Joystick; Flug- und Parkour-Spiele → Vollbild-Touch. Virtual Joystick: Grenzenbegrenzung und Richtungsnormalisierung beachten.
Wie Grenzenbegrenzung und Normalisierung beim Virtual Joystick?
TOUCH_MOVE-Event für Touch-Position, inverseTransformPoint für lokale Node-Koordinaten. Abstand distance zum Zentrum berechnen; bei distance > radius proportional auf radius skalieren. normalize() liefert Einheitsvektor für die Geschwindigkeitsmultiplikation im Bewegungs-Skript.
Warum Timer statt Tastenloslassen für Rückkehr zu idle?
Angriff ist eine einmalige Aktion mit vollständiger Animation – unabhängig von der Tastendruckdauer. Kurz oder lang drücken: gleicher Ablauf – attack-Animation → nach 0,5 s idle. Der Timer stellt sicher, dass die Animation vollständig abläuft.

9 Min. Lesezeit · Veröffentlicht am: 21. Mai 2026 · Aktualisiert am: 14. Juli 2026

Ähnliche Beiträge

Kommentare

Melde dich mit GitHub an, um einen Kommentar zu hinterlassen

Easton BlogEaston Blog