feat: add gacha

feature/ads-smart-display
tiendat3699 2024-06-10 20:13:32 +07:00
parent 73d3848b3a
commit aa57deaa58
10 changed files with 774 additions and 1 deletions

View File

@ -0,0 +1,8 @@
import { _decorator, Component, Node } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('GachaBase')
export default abstract class GachaBase extends Component {
public abstract show(): void;
}

View File

@ -0,0 +1,282 @@
import { _decorator, Component, Enum, Node, setPropertyEnumType, sp } from 'cc';
const { ccclass, property, type } = _decorator;
export enum SocketPath {}
Enum(SocketPath);
export enum SpineAnimation {}
Enum(SpineAnimation);
export enum SpineSkin {}
Enum(SpineSkin);
@ccclass('SpineAnimationHandler')
export default class SpineAnimationHandler extends Component {
@property(sp.Skeleton)
private skeleton: sp.Skeleton;
@property({ visible: true })
private _flipX: boolean = false;
@property({ visible: true })
private _flipY: boolean = false;
public get flipX() {
return this._flipX;
}
public set flipX(value: boolean) {
if (value) {
this.skeleton._skeleton.scaleX = -Math.abs(this.skeleton._skeleton.scaleX);
} else {
this.skeleton._skeleton.scaleX = Math.abs(this.skeleton._skeleton.scaleX);
}
this._flipX = value;
}
public get flipY() {
return this._flipY;
}
public set flipY(value: boolean) {
if (value) {
this.skeleton._skeleton.scaleY = -Math.abs(this.skeleton._skeleton.scaleY);
} else {
this.skeleton._skeleton.scaleY = Math.abs(this.skeleton._skeleton.scaleY);
}
this._flipX = value;
}
private _enumSocketPath = Enum({});
private _enumAnimation = Enum({});
private _enumSkin = Enum({});
onFocusInEditor(): void {
this.setPropertySocketPath(this, '');
}
protected onLoad(): void {
this.skeleton._skeleton.scaleX = this._flipX ? -this.skeleton._skeleton.scaleX : this.skeleton._skeleton.scaleX;
this.skeleton._skeleton.scaleY = this._flipY ? -this.skeleton._skeleton.scaleY : this.skeleton._skeleton.scaleY;
}
public setPropertySocketPath(object: object, propertyName: string) {
//update enum SocketPath
let pathEnum = {};
this.skeleton.querySockets().forEach((path, i) => {
pathEnum[path] = i;
});
this._enumSocketPath = Enum({});
Object.assign(this._enumSocketPath, pathEnum);
Enum.update(this._enumSocketPath);
setPropertyEnumType(object, propertyName, this._enumSocketPath);
}
public setPropertySpineAnimation(object: object, propertyName: string) {
//update enum Animations
let animEnum = this.skeleton.skeletonData.getAnimsEnum();
this._enumAnimation = Enum({});
Object.assign(this._enumAnimation, animEnum);
Enum.update(this._enumAnimation);
setPropertyEnumType(object, propertyName, this._enumAnimation);
}
public setPropertySpineSkin(object: object, propertyName: string) {
//update enum Skin
let skinEnum = this.skeleton.skeletonData.getSkinsEnum();
this._enumSkin = Enum({});
Object.assign(this._enumSkin, skinEnum);
Enum.update(this._enumSkin);
setPropertyEnumType(object, propertyName, this._enumSkin);
}
public setAnimation(
animName: string,
options?: {
loop?: boolean;
trackIndex?: number;
ignoreAnimationRunning?: boolean;
onComplete?: (entry: sp.spine.TrackEntry) => void;
},
): sp.spine.TrackEntry {
if (!this.skeleton) return null;
const opts = {
trackIndex: 0,
loop: false,
...options,
};
if (opts.ignoreAnimationRunning) {
const trackEntry = this.skeleton.getCurrent(opts.trackIndex);
const animRunning = trackEntry?.animation?.name;
if (animRunning && animRunning == animName) return trackEntry;
}
this.skeleton.setCompleteListener(null);
const trackEntry = this.skeleton.setAnimation(opts?.trackIndex || 0, animName, opts?.loop);
if (opts.onComplete) {
this.skeleton.setCompleteListener(opts.onComplete);
}
return trackEntry;
}
public setListener(listener: (entry: sp.spine.TrackEntry, e: sp.spine.Event) => void) {
this.skeleton.setEventListener(listener);
}
public setAnimationAsync(
animName: string,
options?: {
loop?: boolean;
trackIndex?: number;
ignoreAnimationRunning?: boolean;
},
): Promise<sp.spine.TrackEntry> {
return new Promise((resolve) => {
this.setAnimation(animName, {
...options,
onComplete(entry) {
resolve(entry);
},
});
});
}
public addAnimation(
animName: string,
options?: {
loop?: boolean;
trackIndex?: number;
mixDuration?: number;
delay?: number;
onComplete?: (entry: sp.spine.TrackEntry) => void;
},
): sp.spine.TrackEntry {
if (!this.skeleton) return null;
const opts = {
trackIndex: 0,
loop: false,
...options,
};
this.skeleton.setCompleteListener(null);
const trackEntry = this.skeleton.addAnimation(opts?.trackIndex || 0, animName, opts?.loop, opts?.delay);
if (opts?.mixDuration) {
trackEntry.mixDuration = opts.mixDuration;
}
if (opts.onComplete) {
this.skeleton.setCompleteListener(opts.onComplete);
}
return trackEntry;
}
public addAnimationAsync(
animName: string,
options?: {
loop?: boolean;
trackIndex?: number;
mixDuration?: number;
delay?: number;
},
): Promise<sp.spine.TrackEntry> {
return new Promise((resolve) => {
this.addAnimation(animName, {
...options,
onComplete(entry) {
resolve(entry);
},
});
});
}
public addEmptyAnimation(options?: {
trackIndex?: number;
mixDuration?: number;
delay?: number;
onComplete?: (entry: sp.spine.TrackEntry) => void;
}): sp.spine.TrackEntry {
if (!this.skeleton) return;
const opts = {
trackIndex: 0,
mixDuration: 0.2,
delay: 0.2,
...options,
};
const trackEntry = this.skeleton.getState().addEmptyAnimation(opts.trackIndex, opts.mixDuration, opts.delay);
if (opts.onComplete) {
this.skeleton.setTrackCompleteListener(trackEntry, opts.onComplete);
}
return trackEntry;
}
public addEmptyAnimationAsync(options?: {
trackIndex?: number;
mixDuration?: number;
delay?: number;
}): Promise<sp.spine.TrackEntry> {
return new Promise((resolve) => {
this.addEmptyAnimation({
...options,
onComplete(entry) {
resolve(entry);
},
});
});
}
public clearTrack(trackIndex: number = 0): void {
if (!this.skeleton) return;
this.skeleton.clearTrack(trackIndex);
}
public findBone(boneName: string): sp.spine.Bone {
return this.skeleton.findBone(boneName);
}
public socketPathToString(socketPath: SocketPath): string {
return this.skeleton.querySockets()[socketPath];
}
public SpineAnimationToString(animationName: SpineAnimation): string {
return this.skeleton._skeleton.data.animations[animationName].name;
}
public SpineAnimationToAnimation(animationName: SpineAnimation): sp.spine.Animation {
return this.skeleton._skeleton.data.animations[animationName];
}
public addSocket(socketPath: SocketPath | string, target: Node): sp.SpineSocket {
let socket: sp.SpineSocket;
if (typeof socketPath === 'string') {
socket = new sp.SpineSocket(socketPath, target);
} else {
socket = new sp.SpineSocket(this.socketPathToString(socketPath), target);
}
this.skeleton.sockets.push(socket);
this.skeleton!.sockets = this.skeleton!.sockets;
return socket;
}
public updateSocketPath(socket: sp.SpineSocket, socketPath: SocketPath | string): sp.SpineSocket {
let newSocket: sp.SpineSocket;
if (typeof socketPath === 'string') {
newSocket = new sp.SpineSocket(socketPath, socket.target);
} else {
newSocket = new sp.SpineSocket(this.socketPathToString(socketPath), socket.target);
}
const index = this.skeleton.sockets.indexOf(socket);
this.skeleton.sockets[index] = newSocket;
this.skeleton!.sockets = this.skeleton!.sockets;
return newSocket;
}
public removeSocket(socket: sp.SpineSocket): void {
const index = this.skeleton.sockets.indexOf(socket);
this.skeleton.sockets.splice(index, 1);
this.skeleton!.sockets = this.skeleton!.sockets;
}
}

View File

@ -0,0 +1,22 @@
import { _decorator, CCBoolean, Component, game, Node, sp } from 'cc';
const { ccclass, property, requireComponent } = _decorator;
@ccclass('SpineIgnoreTimeScale')
@requireComponent(sp.Skeleton)
export default class SpineIgnoreTimeScale extends Component {
@property(CCBoolean)
private ignoreTimeScale: boolean = false;
private _skeleton: sp.Skeleton;
protected onLoad(): void {
this._skeleton = this.getComponent(sp.Skeleton);
}
protected update(dt: number): void {
if (game.timeScale != 1) {
this._skeleton.updateAnimation(this.ignoreTimeScale ? game.deltaTime : dt);
}
}
}

View File

@ -3,6 +3,7 @@ import BoosterType from '../Enum/BoosterType';
import ControllerSide from '../Enum/ControllerSide';
import GameState from '../Enum/GameState';
import ScoreType from '../Enum/ScoreType';
import { RewardType } from '../Manager/GachaManager';
enum GameEvent {
Score,
@ -17,6 +18,7 @@ enum GameEvent {
ControlTouchEnd,
WarningTime,
TicketUpdate,
GachaReward,
}
export interface GameEventCallbackMap {
@ -47,6 +49,7 @@ export interface GameEventArgMap {
[GameEvent.ControlTouchEnd]: ControllerSide;
[GameEvent.WarningTime]: boolean;
[GameEvent.TicketUpdate]: number;
[GameEvent.GachaReward]: [RewardType, number];
}
export default GameEvent;

View File

@ -0,0 +1,63 @@
import { _decorator, Component, Node, Sprite } from 'cc';
import GachaBase from '../Base/GachaBase';
import SpineAnimationHandler from '../Base/SpineAnimationHandler';
import GachaManager from '../Manager/GachaManager';
const { ccclass, property } = _decorator;
@ccclass('FlipCard')
export default class FlipCard extends GachaBase {
@property(SpineAnimationHandler)
private animationHandler: SpineAnimationHandler;
@property(Node)
private spineRoot: Node;
@property(Sprite)
private cards: Sprite[] = [];
private _opened: boolean = false;
protected onLoad(): void {
this.animationHandler.setListener((_, e) => {
switch (e.data.name) {
case 'card1-active':
this.cards[0].setNodeActive(true);
break;
case 'card2-active':
this.cards[1].setNodeActive(true);
break;
case 'card3-active':
this.cards[2].setNodeActive(true);
break;
case 'card4-active':
this.cards[3].setNodeActive(true);
break;
}
});
}
protected onEnable(): void {
this._opened = false;
this.cards.forEach((card) => card.setNodeActive(false));
this.spineRoot.setActive(false);
}
public async show(): Promise<void> {
this.spineRoot.setActive(true);
await this.animationHandler.setAnimationAsync('appear');
this.animationHandler.addAnimation('idle', { loop: true });
}
public async open(event: Event, value: string) {
if (this._opened) return;
this._opened = true;
if (this.cards[+value - 1].node.active) return;
const reward = await GachaManager.instance.getReward();
if (reward) {
this.cards[+value - 1].spriteFrame = reward.icon;
await this.animationHandler.setAnimationAsync(`card${value}-active`, { trackIndex: +value });
GachaManager.instance.gachaDone();
}
GachaManager.instance.gachaDone();
}
}

View File

@ -0,0 +1,70 @@
import { _decorator, clamp01, Component, easing, game, Label, Node, Sprite, Vec3 } from 'cc';
import GachaBase from '../Base/GachaBase';
import SpineAnimationHandler from '../Base/SpineAnimationHandler';
import GachaManager from '../Manager/GachaManager';
import Utils from '../Utilities';
const { ccclass, property } = _decorator;
@ccclass('FreeReward')
export default class FreeReward extends GachaBase {
@property(SpineAnimationHandler)
private animationHandler: SpineAnimationHandler;
@property(Node)
private spineRoot: Node;
@property(Sprite)
private rewardSprite: Sprite;
@property(Label)
private rewardLabel: Label;
private _opened: boolean = false;
private _startShowReward: boolean = false;
private _timer: number = 0;
protected onEnable(): void {
this.spineRoot.setActive(false);
this.rewardSprite.setNodeActive(false);
this._opened = false;
}
protected update(dt: number): void {
if (this._startShowReward) {
let k = clamp01(this._timer / 0.3);
k = easing.quintInOut(k);
const targetScale = Vec3.lerp(this.rewardSprite.node.scale, Vec3.ZERO, Vec3.ONE, k);
const targetPosition = Vec3.lerp(this.rewardSprite.node.position, new Vec3(0, 150), new Vec3(0, 700), k);
this.rewardSprite.node.setScale(targetScale);
this.rewardSprite.node.setPosition(targetPosition);
this._timer += game.deltaTime;
if (k === 1) {
this._startShowReward = false;
GachaManager.instance.gachaDone();
}
}
}
public async show() {
this.spineRoot.setActive(true);
await this.animationHandler.setAnimationAsync('appear');
this.animationHandler.addAnimation('idle', { loop: true });
}
public async open() {
if (this._opened) return;
this._timer = 0;
this._opened = true;
const reward = await GachaManager.instance.getReward();
if (reward) {
this.animationHandler.setAnimation('open');
this.rewardSprite.spriteFrame = reward.icon;
this.rewardLabel.string = reward.quantity.toString();
await Utils.delay(1);
this.rewardSprite.setNodeActive(true);
this._startShowReward = true;
return;
}
this._startShowReward = false;
GachaManager.instance.gachaDone();
}
}

View File

@ -0,0 +1,105 @@
import { _decorator, Component, Node, sp, Sprite } from 'cc';
import GachaBase from '../Base/GachaBase';
import SpineAnimationHandler, { SocketPath, SpineAnimation, SpineSkin } from '../Base/SpineAnimationHandler';
import GachaManager from '../Manager/GachaManager';
import Utils from '../Utilities';
const { ccclass, property } = _decorator;
class ActiveCard {
public socket: sp.SpineSocket;
public node: Node;
public idReward: string;
constructor(node: Node, idReward: string) {
this.node = node;
this.idReward = idReward;
}
public setActive(value: boolean) {
this.node.setActive(value);
}
}
@ccclass('LuckyChain')
export default class LuckyChain extends GachaBase {
@property(SpineAnimationHandler)
private animationHandler: SpineAnimationHandler;
@property(Node)
private spriteRoot: Node;
@property(Node)
private cards: Node[] = [];
@property({ type: SocketPath })
private cardSlotPath: SocketPath[] = [];
private _activeCards: ActiveCard[] = [];
private _allCards: ActiveCard[] = [];
private _rewardId: string;
onFocusInEditor(): void {
this.animationHandler.setPropertySocketPath(this, 'cardSlotPath');
}
protected onLoad(): void {
this.setReward();
for (let i = 0; i < 4; i++) {
const card = this._allCards.shift();
const path = this.cardSlotPath[i];
const socket = this.animationHandler.addSocket(path, card.node);
card.setActive(true);
card.socket = socket;
this._activeCards.push(card);
}
this.animationHandler.setListener((_, e) => {
switch (e.data.name) {
case 'card-claimed':
const cardRemove = this._activeCards.shift();
this._rewardId = cardRemove.idReward;
this.animationHandler.removeSocket(cardRemove.socket);
cardRemove.setActive(false);
cardRemove.socket = null;
this._allCards.push(cardRemove);
break;
case 'new-card-spawned':
const card = this._allCards.shift();
const path = this.cardSlotPath[this.cardSlotPath.length - 1];
const socket = this.animationHandler.addSocket(path, card.node);
card.setActive(true);
card.socket = socket;
this._activeCards.push(card);
break;
}
});
}
protected onEnable(): void {
this.spriteRoot.setActive(false);
}
private setReward() {
this._allCards = this.cards.map((card) => {
const rw = GachaManager.instance.getRandomReward();
card.getComponent(Sprite).spriteFrame = rw.icon;
card.setActive(false);
return new ActiveCard(card, rw.id);
});
}
public async show(): Promise<void> {
this.spriteRoot.setActive(true);
await this.animationHandler.setAnimationAsync('appear');
await this.animationHandler.addAnimationAsync('active');
GachaManager.instance.setReward(this._rewardId);
await Utils.delay(1.5);
//update socket path
for (let i = 0; i < 4; i++) {
const card = this._activeCards[i];
const path = this.cardSlotPath[i];
this._activeCards[i].socket = this.animationHandler.updateSocketPath(card.socket, path);
}
}
}

View File

@ -0,0 +1,81 @@
import { _decorator, CCInteger, CCString, Component, game, Node, randomRangeInt, RealCurve, sp, Sprite } from 'cc';
import GachaBase from '../Base/GachaBase';
import SpineAnimationHandler from '../Base/SpineAnimationHandler';
import GachaManager from '../Manager/GachaManager';
const { ccclass, property } = _decorator;
@ccclass('LuckyWheel')
export default class LuckyWheel extends GachaBase {
@property(SpineAnimationHandler)
private animationHandler: SpineAnimationHandler;
@property(Node)
private spineRoot: Node;
@property(CCString)
private wheelBoneName: string = '';
@property(CCInteger)
private speed: number = 1;
@property(RealCurve)
private spinCurve: RealCurve = new RealCurve();
@property(Sprite)
private sprites: Sprite[] = [];
private _wheel: sp.spine.Bone;
private _targetAngle: number = 0;
private _spinning: boolean = false;
private _timer: number = 0;
private _timeSpin: number = 0;
private _maxAngle: number = 0;
protected onLoad(): void {
this._wheel = this.animationHandler.findBone(this.wheelBoneName);
}
protected onEnable(): void {
this.spineRoot.setActive(false);
this._wheel.rotation = 0;
this._wheel.update();
}
protected update(): void {
if (this._spinning) {
this._timer += game.deltaTime * this.speed;
const angle = this._maxAngle * this.spinCurve.evaluate(this._timer / this._timeSpin);
this._wheel.rotation = this._targetAngle + angle;
this._wheel.update();
if (this._timer >= this._timeSpin) {
this._spinning = false;
GachaManager.instance.gachaDone();
}
}
}
public async show(): Promise<void> {
for (let i = 0; i < GachaManager.instance.rewards.length; i++) {
this.sprites[i].spriteFrame = GachaManager.instance.rewards[i].icon;
}
this.spineRoot.setActive(true);
await this.animationHandler.setAnimationAsync('appear');
this.animationHandler.addAnimation('idle', { loop: true });
}
public async spin() {
if (this._spinning) return;
this._spinning = true;
const reward = await GachaManager.instance.getReward();
const item = GachaManager.instance.getRewardIndex(reward);
if (reward) {
this.animationHandler.clearTrack(0);
this._targetAngle = -36 * item;
this._timer = 0;
this._timeSpin = randomRangeInt(10, 15);
this._maxAngle = 360 * this._timeSpin;
return;
}
this._spinning = true;
GachaManager.instance.gachaDone();
}
}

View File

@ -0,0 +1,138 @@
import {
_decorator,
CCInteger,
CCString,
clamp01,
Component,
easing,
Enum,
game,
lerp,
Node,
SpriteFrame,
tween,
UIOpacity,
} from 'cc';
import GachaBase from '../Base/GachaBase';
import GameEvent from '../Events/GameEvent';
import Singleton from '../Singleton';
import Utils from '../Utilities';
import { EventManger } from './EventManger';
const { ccclass, property } = _decorator;
export enum GachaType {
FreeReward,
LuckyWheel,
LuckyChain,
FlipCard,
}
Enum(GachaType);
export enum RewardType {
Oxi,
Gas,
Star,
Shield,
ScoreX2,
Magnet,
}
Enum(RewardType);
@ccclass('Gacha')
class Gacha {
@property(CCString)
public id: string = '';
@property({ type: GachaType })
public type: GachaType = GachaType.FreeReward;
@property(GachaBase)
public gacha: GachaBase;
}
@ccclass('Reward')
class RewardConfig {
@property(CCString)
public id: string = '';
@property({ type: RewardType })
public type: RewardType = RewardType.Oxi;
@property(SpriteFrame)
public icon: SpriteFrame;
@property(CCInteger)
public quantity: number;
}
@ccclass('GachaManager')
export default class GachaManager extends Singleton<GachaManager>() {
@property(CCString)
public gachaId: string;
@property(UIOpacity)
private container: UIOpacity;
@property(Gacha)
private gachas: Gacha[] = [];
@property(RewardConfig)
public rewards: RewardConfig[] = [];
private _reward: RewardConfig;
private _showing: boolean = false;
private _showTimer: number = 0;
private _idType: string;
protected onLoad(): void {
super.onLoad();
this.container.setNodeActive(false);
}
public show(idType: string) {
game.timeScale = 0;
this.container.setNodeActive(true);
this._showTimer = 0;
this._showing = true;
this._idType = idType;
}
protected update(dt: number): void {
if (this._showing) {
this._showTimer += game.deltaTime;
const k = easing.smooth(clamp01(this._showTimer / 0.2));
this.container.opacity = lerp(0, 255, k);
if (k === 1) {
this._showing = false;
this.showGacha();
}
}
}
private showGacha() {
const gacha = this.gachas.find((gacha) => gacha.id == this._idType);
gacha.gacha.show();
}
public async getReward(): Promise<RewardConfig> {
this._reward = this.getRandomReward();
return this._reward;
}
public getRewardIndex(reward: RewardConfig): number {
return this.rewards.indexOf(reward);
}
public async gachaDone() {
console.log(`Gacha reward: ${RewardType[this._reward.type]} quantity: ${this._reward.quantity}`);
EventManger.instance.emit(GameEvent.GachaReward, [this._reward.type, this._reward.quantity]);
await Utils.delay(1);
game.timeScale = 1;
tween(this.container)
.to(0.1, { opacity: 0 })
.call(() => this.container.setNodeActive(false))
.start();
}
public getRandomReward() {
return this.rewards.getRandom();
}
public setReward(id: string) {
this._reward = this.rewards.find((r) => r.id == id);
this.gachaDone();
}
}

View File

@ -110,7 +110,7 @@
"_value": false
},
"spine": {
"_value": false
"_value": true
},
"dragon-bones": {
"_value": false
@ -138,6 +138,7 @@
"particle-2d",
"physics-2d-box2d",
"profiler",
"spine",
"tween",
"ui",
"websocket"