BlindRankGameplay
BlindRankGameplay.ts
Template component for TikTok / Snap effects that drives a blind-ranking mini-game. Handles texture randomisation, slot management, touch interactions, and smooth bezier-curve animations. Drop it into Lens Studio (APJS) and wire up your textures in the editor — no extra dependencies needed.
// Blind Rank Gameplay Component
// Main component controlling the blind ranking game mechanics
// Handles randomization, slot management, touch interactions, and animations
// Please keep BlindRankGameplay.ts in the Assets root folder
@component()
export class BlindRankGameplay extends APJS.BasicScriptComponent {
@serializeProperty
private waitBetweenFrames: number = 0.05;
@serializeProperty
private tapToStopRandomizer: boolean = true;
@serializeProperty
private spaceBetweenEach: number = 5;
@serializeProperty
private randomizerTextures: APJS.Texture[] = [];
@serializeProperty
private defaultSlotTexture: APJS.Texture;
@serializeProperty
private UICameraObject: APJS.SceneObject;
@serializeProperty
private randomizer: APJS.SceneObject;
@serializeProperty
private optionSlotsContainer: APJS.SceneObject;
@serializeProperty
private tapScreenPrompt: APJS.SceneObject;
// Randomizer
private randomImage: APJS.Image;
private shuffledTextures: APJS.Texture[] = [];
private currentTextureIndex: number = 0;
private textureChangeTimer: number = 0;
private isRandomizing: boolean = false;
// Option Slots
private readonly scaleFactor: number = 0.1;
private readonly yOffset: number = 100;
private optionSlots: APJS.SceneObject[] = [];
private optionSlotSize: APJS.Vector2f = new APJS.Vector2f(0, 0);
private uiCamera: APJS.Camera;
private clickedIndices: number[] = [];
private isGameOver: boolean = false;
// Text Moving Animation
private textMoveObjectsMap: Map<
APJS.SceneObject,
{ timer: number; moveRange: APJS.Vector3f[]; hasMoved: boolean }
> = new Map();
private textMoveDuration: number = 0.2;
private readonly moveDistance: number = 2;
// Bezier combination cache
private combNum: number[][] = [];
private combination(n: number, i: number) {
if (i < 0 || n === 0) return 0;
if (this.combNum[n] && this.combNum[n][i]) return this.combNum[n][i];
if (this.combNum[n] === undefined) this.combNum[n] = [];
if (i === 0 || n === i) {
this.combNum[n][i] = 1;
} else {
this.combNum[n][i] =
this.combination(n - 1, i) + this.combination(n - 1, i - 1);
}
return this.combNum[n][i];
}
private bezier(nums: number[], t: number) {
const p = 1 - t;
const item = nums;
let p1 = 1;
let t1 = 1;
for (let i = 0; i < nums.length; ++i) {
item[i] = item[i] * t1;
item[nums.length - i - 1] = item[nums.length - i - 1] * p1;
item[i] = item[i] * this.combination(nums.length - 1, i);
p1 = p1 * p;
t1 = t1 * t;
}
return item.reduce((pre, cur) => pre + cur, 0);
}
private getCurNumber(numStart: number, numEnd: number, t: number, duration: number) {
t = duration === 0 ? 0 : t / duration;
return this.bezier([numStart, numStart, numEnd, numEnd], t);
}
private getCurVector3f(startPos: APJS.Vector3f, endPos: APJS.Vector3f, t: number, duration: number) {
return new APJS.Vector3f(
this.getCurNumber(startPos.x, endPos.x, t, duration),
this.getCurNumber(startPos.y, endPos.y, t, duration),
this.getCurNumber(startPos.z, endPos.z, t, duration)
);
}
private initOptionSlots() {
if (!this.optionSlotsContainer) return;
this.optionSlots = this.optionSlotsContainer.getChildren();
const numSlotsToDisplay = this.randomizerTextures.length;
const containerTransform = this.optionSlotsContainer.getComponent(
"ScreenTransform"
) as APJS.ScreenTransform;
if (!containerTransform) return;
this.uiCamera = this.UICameraObject.getComponent("Camera") as APJS.Camera;
if (!this.uiCamera) return;
const slotsToDisplay = this.optionSlots.slice(0, numSlotsToDisplay);
if (slotsToDisplay.length > 0) {
const firstSlotTransform = slotsToDisplay[0].getComponent(
"ScreenTransform"
) as APJS.ScreenTransform;
if (!firstSlotTransform) return;
const singleSlotHeight = firstSlotTransform.sizeDelta.x * this.scaleFactor;
const totalSpacingHeight = (slotsToDisplay.length - 1) * this.spaceBetweenEach;
const totalHeight = singleSlotHeight * slotsToDisplay.length + totalSpacingHeight;
this.optionSlotSize = new APJS.Vector2f(singleSlotHeight, singleSlotHeight);
const startY = totalHeight / 2 - singleSlotHeight / 2;
slotsToDisplay.forEach((slot, index) => {
const slotTransform = slot.getComponent("ScreenTransform") as APJS.ScreenTransform;
if (slotTransform) {
const yPosition =
-startY +
(slotsToDisplay.length - 1 - index) *
(singleSlotHeight + this.spaceBetweenEach);
slotTransform.anchoredPosition = new APJS.Vector2f(
slotTransform.anchoredPosition.x,
yPosition + this.yOffset
);
slotTransform.scale = new APJS.Vector2f(this.scaleFactor, this.scaleFactor);
slot.enabled = true;
}
const slotImage = slot.getComponent("Image") as APJS.Image;
if (slotImage) slotImage.texture = this.defaultSlotTexture;
});
}
if (this.textMoveObjectsMap.size > 0) {
this.textMoveObjectsMap.forEach((moveInfo, textObj) => {
textObj.getTransform().setWorldPosition(moveInfo.moveRange[0]);
});
this.textMoveObjectsMap.clear();
}
for (let i = numSlotsToDisplay; i < this.optionSlots.length; i++) {
this.optionSlots[i].enabled = false;
}
}
private getTextureIndex() {
return this.currentTextureIndex === 0
? this.shuffledTextures.length - 1
: this.currentTextureIndex - 1;
}
private startRandomizer(indexToRemove?: number, showTapScreenPrompt: boolean = false) {
if (this.isRandomizing) return;
if (indexToRemove !== undefined) {
this.shuffledTextures = this.shuffledTextures.filter((_, i) => i !== indexToRemove);
} else {
this.shuffledTextures = [...this.randomizerTextures];
}
this.shuffledTextures.sort(() => Math.random() - 0.5);
this.currentTextureIndex = 0;
this.textureChangeTimer = 0;
this.isRandomizing = true;
if (this.tapToStopRandomizer && showTapScreenPrompt) {
this.tapScreenPrompt.enabled = true;
}
}
private checkClickOptionSlot(viewportPosition: APJS.Vector2f): number {
if (this.optionSlots.length < 2) return -1;
const allSlotPositions = this.optionSlots.map((slot) => {
const st = slot.getComponent("ScreenTransform") as APJS.ScreenTransform;
return st
? this.uiCamera.worldToViewportPoint(st.getWorldPosition())
: new APJS.Vector3f(-1, -1, 0);
});
let halfSlotSize = 0;
if (allSlotPositions.length > 1) {
halfSlotSize = Math.abs(allSlotPositions[1].y - allSlotPositions[0].y) / 2;
} else {
halfSlotSize = this.optionSlotSize.x / 2000;
}
if (isNaN(halfSlotSize) || halfSlotSize <= 0) {
halfSlotSize = this.optionSlotSize.x / 2000;
}
for (let i = 0; i < this.optionSlots.length; i++) {
if (!this.optionSlots[i].enabled) continue;
const sp = allSlotPositions[i];
if (
viewportPosition.x >= sp.x - halfSlotSize &&
viewportPosition.x <= sp.x + halfSlotSize &&
viewportPosition.y >= sp.y - halfSlotSize &&
viewportPosition.y <= sp.y + halfSlotSize
) {
return i;
}
}
return -1;
}
private updateClickedOptionSlot(index: number) {
if (index < 0 || index >= this.optionSlots.length) return;
const clickedSlot = this.optionSlots[index];
const slotImage = clickedSlot.getComponent("Image") as APJS.Image;
if (!slotImage || !this.randomImage) return;
slotImage.texture = this.shuffledTextures[this.getTextureIndex()];
const children = clickedSlot.getChildren();
const textObjIndex = children.findIndex((child) => child.name === "Text");
if (textObjIndex !== -1) {
const startPos = children[textObjIndex].getTransform().getWorldPosition();
const endPos = new APJS.Vector3f(startPos.x - this.moveDistance, startPos.y, startPos.z);
this.textMoveObjectsMap.set(children[textObjIndex], {
timer: 0,
moveRange: [startPos, endPos],
hasMoved: false,
});
}
}
private fillSlotAtIndex(clickedIndex: number, startNext = true) {
this.updateClickedOptionSlot(clickedIndex);
this.clickedIndices.push(clickedIndex);
if (this.clickedIndices.length === this.optionSlots.length) {
this.stopRandomizer();
this.isGameOver = true;
if (this.randomizer) this.randomizer.enabled = false;
return;
}
if (startNext) {
this.startRandomizer(this.getTextureIndex());
} else {
this.tapScreenPrompt.enabled = false;
}
}
private onTouchEvent = (event: APJS.IEvent) => {
const touchInfo = event.args[0] as APJS.TouchData;
switch (touchInfo.phase) {
case APJS.TouchPhase.Began:
if (this.isGameOver) break;
if (this.tapToStopRandomizer && this.isRandomizing) {
this.stopRandomizer();
break;
}
if (!this.isRandomizing) {
const clickedIndex = this.checkClickOptionSlot(
new APJS.Vector2f(touchInfo.position.x, 1 - touchInfo.position.y)
);
const cachedIndex = this.clickedIndices.findIndex((i) => i === clickedIndex);
if (
clickedIndex !== -1 &&
cachedIndex === -1 &&
this.clickedIndices.length < this.optionSlots.length
) {
this.fillSlotAtIndex(clickedIndex);
}
}
break;
}
};
private onRecordStart = (_event: APJS.IEvent) => {
this.stopRandomizer();
this.isGameOver = false;
if (this.randomizer) this.randomizer.enabled = true;
this.initOptionSlots();
this.startRandomizer(undefined, true);
this.clickedIndices = [];
};
private stopRandomizer() {
this.isRandomizing = false;
this.tapScreenPrompt.enabled = false;
}
onStart() {
if (this.randomizer) {
this.randomImage = this.randomizer.getComponent("Image") as APJS.Image;
}
this.initOptionSlots();
this.startRandomizer(undefined, true);
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.Touch, this.onTouchEvent);
APJS.EventManager.getGlobalEmitter().on(APJS.EventType.RecordStart, this.onRecordStart);
}
onUpdate(deltaTime: number) {
if (this.isRandomizing && this.randomImage) {
this.textureChangeTimer += deltaTime;
if (this.textureChangeTimer >= this.waitBetweenFrames) {
this.randomImage.texture = this.shuffledTextures[this.currentTextureIndex];
this.currentTextureIndex++;
this.textureChangeTimer = 0;
if (this.tapToStopRandomizer) {
if (this.currentTextureIndex >= this.shuffledTextures.length) {
this.currentTextureIndex = 0;
}
} else {
if (this.currentTextureIndex >= this.shuffledTextures.length) {
this.stopRandomizer();
}
}
}
}
if (this.textMoveObjectsMap.size > 0) {
for (const [textObj, moveInfo] of this.textMoveObjectsMap) {
if (!textObj || !moveInfo || moveInfo.moveRange.length !== 2 || moveInfo.hasMoved) continue;
textObj.getTransform().setWorldPosition(
this.getCurVector3f(moveInfo.moveRange[0], moveInfo.moveRange[1], moveInfo.timer, this.textMoveDuration)
);
moveInfo.timer += deltaTime;
if (moveInfo.timer >= this.textMoveDuration) {
moveInfo.timer = 0;
moveInfo.hasMoved = true;
textObj.getTransform().setWorldPosition(moveInfo.moveRange[1]);
}
}
}
}
onDestroy(): void {
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.Touch, this.onTouchEvent);
APJS.EventManager.getGlobalEmitter().off(APJS.EventType.RecordStart, this.onRecordStart);
}
}