Home Projects Blog About Contact
Download CV
TikTok / Lens Studio TypeScript 2025

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.

TypeScriptLens StudioAPJSTikTok EffectRandomizerGame
BlindRankGameplay.ts
TypeScript
// 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);
  }
}