Skip to content

t1k:cocos:playable:gameflow

FieldValue
Moduleplayable
Version0.15.0
Efforthigh
Tools

Keywords: ads, cocos, CTA, end card, game flow, gameflow, loading, playable, UI views

/t1k:cocos:playable:gameflow

This skill handles game states, views, loading screens, end cards, and CTA integration. Does NOT handle parameter definitions (use t1k-cocos-playable-parameter) or SDK adapters (use t1k-cocos-playable-sdk-core).

LOADING -> FTUE -> GAMEPLAY -> WIN / LOSE
export enum GameState { LOADING, FTUE, GAMEPLAY, WIN, LOSE }

Ads that chain several rounds add one interstitial state between rounds:

LOADING -> FTUE -> GAMEPLAY -> [CHAPTER_RESULT -> GAMEPLAY]* -> WIN / LOSE

Route every round outcome — win, lose, and timeout alike — through a single onLevelFinished(won) handler that decides “interstitial vs end card”. A secondary path that calls onWin()/onLose() directly (a countdown timer is the usual culprit) skips the chain entirely and ends the ad on the first loss.

  • GameView (singleton) — State machine. onWin(), onLose(), onTutorialComplete()
  • LoadingView — Progress bar, waits for AllAsyncParametersReadySignal. Bound via LoadingScreenParameter (replaces old scattered LoadingBackgroundColor + LoadingIcon + LoadingGameName params).
  • EndCardView (abstract) — Base for end cards, plays audio, triggers CTA. Exposes backgroundSprite, titleLabel, subtitleLabel for ParameterBinder.bindEndCard().
  • WinView / LoseView — Extend EndCardView, provide audio name. Bound via EndCardParameter (replaces old EndCardWinCTA / EndCardLoseCTA params).
  • CTAService — Routes CTA click to correct store per SDK
  • PlayableHelper — First touch -> BGM, optional redirect-after-N-clicks

EndCardParameter and LoadingScreenParameter are composite types that bundle all view fields into one dashboard group. Use ParameterBinder to apply them:

// In ParameterController.SetUpOnUpdate():
PlayableConfig.EndCardWin.onUpdate = (config) => {
const p = ParameterBinder.bindEndCard(this.winView, config);
if (p) this._spriteUpdatePromises.push(p);
};
PlayableConfig.LoadingScreen.onUpdate = (config) => {
const p = ParameterBinder.bindLoadingScreen(this.loadingView, config);
if (p) this._spriteUpdatePromises.push(p);
};

See t1k-cocos-playable-parameter skill for composite type definitions and migration guide.

  1. Create component extending Component in assets/scripts/UI/
  2. Add @property(Node) reference in GameView.ts
  3. Hide in GameView.hideAllViews()
  4. Add transition method (e.g., onMyState()) setting currentState + activating view
  5. Wire parameters if needed (use t1k-cocos-playable-parameter skill)
  6. Assign in Cocos Editor Inspector
ParametersReadySignal -- sync params ready
AllAsyncParametersReadySignal -- async params loaded (LoadingView waits for this)
FirstInteractionSignal -- user first touch (InputService)
TapSignal / SwipeSignal -- user input events

Always use db:// protocol for cross-submodule imports:

import { SignalBus } from "db://assets/PLAGameFoundation/signalBus/SignalBus";
import { AudioService } from "db://assets/PLAGameFoundation/gameControl/utilities/AudioSystem/AudioService";
import { SdkType } from "db://assets/PlayableParamterTool/GameConfig";
import { GameView } from "db://assets/scripts/UI/GameView";
AudioService.instance.playMusic(Constant.AUDIO_NAME.BGM);
AudioService.instance.playSFX(Constant.AUDIO_NAME.WIN);

Audio files in resources/audio/. Register names in constant.ts -> AUDIO_NAME.

// constant.ts: STORE_LINK = { ANDROID_LINK, IOS_LINK }
// CTAService routes per SDK: THE_ONE -> gameEndHandler, VOODOO -> ParameterManager.redirect()

See references/gameflow-code-examples.md for complete implementation patterns.

  • NEVER extend EndCardView for an interstitial (between-rounds) view. EndCardView.onEnable() calls setupCTA() unconditionally, and when fullScreenCTA is true it binds TOUCH_END on the whole node → CTAService.handleCTAClick(). A card shown mid-gameplay would redirect the player to the store on any tap. Write a plain Component (~80 lines) instead; carving a “disable CTA” hole into the base class breaks the two real end cards that depend on it. Also give the interstitial a cc.BlockInputEvents: Cocos dispatches a touch to the topmost node that registers a listener, so a bare dimming Sprite overlay does NOT stop taps from reaching the live board underneath.
  • A restartable level breaks everything written for a one-shot level. The moment a round can rebuild in place (chapter chains, retry-on-lose), audit for: state armed by a once-per-playthrough signal (a level-selected signal fires once, but per-round freezes fire every round); scheduleOnce callbacks that outlive teardown and land on the next round; pooled visual state left dirty by the previous round; and values captured lazily on first use, which then inherit round N−1 instead of the editor default. Capture such defaults exactly once in onLoad(), and call unscheduleAllCallbacks() in teardown.
  • GameView lifecycle owns the FSM, not vice versa — destroying GameView while FSM holds a reference dangles the state’s this.
  • Scene transitions during state.enter() are a footgun — the next state runs onLoad against an unmounted parent.
  • Inspector-wired callback params must guard with typeof param === 'function', never bare ?.() or a truthy check. Any public method you wire from the Cocos Inspector via an EventHandler (Button clickEvents, view onWin/onLose/CTA handlers, custom [EventHandler] arrays) is invoked by Cocos with the EventHandler’s CustomEventData string as its FIRST argument — NOT undefined. So an optional-callback param guarded with onComplete?.() (guards only null/undefined) or if (onComplete) (a non-empty string is truthy) throws onComplete is not a function when fired from the Inspector. Guard with if (typeof onComplete === 'function') onComplete(); so the same handler works called from code OR from an Inspector EventHandler (string arg ignored, no crash).
  • Audio clips resolve ONLY from the in-scene AudioContainer.audioList. AudioService.playMusic(name) / playSfx(name) look the clip up via AudioContainer.instance.getAudioClip(name), and AudioContainer.buildAudioMap() keys clips by clip.name (the asset basename, no extension) built solely from the inspector-assigned audioList: AudioClip[]. There is no resources/audio fallback — a name not present in audioList returns null and only logs a warning (silent no-sound). So a new BGM/SFX must be (1) added to the scene’s AudioContainer component audioList, and (2) referenced by a name that exactly matches the asset basename (case- and space-sensitive). AUDIO_NAME comments like “place X.mp3 in resources/audio” are misleading — the resources path is not consulted.