leonitousconforti/tinyburg

View on GitHub
packages/doorman/src/handlers/elevator-handler.ts

Summary

Maintainability
A
0 mins
Test Coverage
import type { PromiseClient } from "@connectrpc/connect";
import type { Image } from "../image-operations/image.js";
import type { ILocationBasedTrigger } from "./base-handler.js";
import type { ICropRegion } from "../image-operations/crop-image.js";
import type { EmulatorController } from "@tinyburg/architect/protobuf/emulator_controller_connect";

import { getScreenshot } from "../grpc/get-screenshots.js";

import { BaseHandler } from "./base-handler.js";
import { calculateResourceScale } from "../utils/calculate-resource-scale.js";

import { BaseAction } from "../actions/base-action.js";
import { ClickAction } from "../actions/click-action.js";

import { ImageType } from "../image-operations/image.js";
import { cropImage } from "../image-operations/crop-image.js";
import { negateImage } from "../image-operations/negate-image.js";
import { dropChannel } from "../image-operations/drop-channel.js";
import { upscaleImage } from "../image-operations/upscale-image.js";
import { matchTemplate } from "../image-operations/template-matching.js";
import { loadTemplateByName, loadCharTemplates } from "../image-operations/load-template.js";
import { detectSequence, numericalImagesDictionary, prepDictionaryToLibrary } from "../image-operations/ocr.js";

// 'Notes' are the boxes that appear in the bottom left of the screen.
// In this case, the red elevator note is called note_ride1
const note_ride1: Image = await loadTemplateByName("note_ride1");

// eslint-disable-next-line @rushstack/typedef-var
const continueTemplates = await loadCharTemplates("C", "O", "N", "T", "I", "N", "U", "E", "?");

export class ElevatorHandler extends BaseHandler<ILocationBasedTrigger> {
    private _templateTriggerMask: Image;
    private _templateTriggerImage: Image;

    public constructor() {
        super("Default Elevator Ride Handler");
        const dropChannelResult = dropChannel(note_ride1, 4, ImageType.RGB);
        this._templateTriggerImage = dropChannelResult.modifiedSourceImage;
        this._templateTriggerMask = negateImage(dropChannelResult.droppedChannelImage);
    }

    public async detectTrigger(screenshot: Image): Promise<ILocationBasedTrigger | undefined> {
        // Scale the template image to the proper size based on the source image
        const resourceScale = calculateResourceScale(screenshot.width, screenshot.height);
        const templateTriggerUpscaled = upscaleImage(this._templateTriggerImage, resourceScale);
        const templateMaskUpscaled = upscaleImage(this._templateTriggerMask, resourceScale);

        // Defines the general area of where to look for the template trigger image
        const sourceTriggerRegion: ICropRegion = {
            left: 0,
            width: screenshot.width,
            height: Math.round(screenshot.height / 8),
            top: screenshot.height - Math.round(screenshot.height / 8),
        };
        const screenshotCropped = cropImage(screenshot, sourceTriggerRegion);

        // Attempt to match the template to the cropped screenshot
        const matches = matchTemplate(screenshotCropped, templateTriggerUpscaled, templateMaskUpscaled);
        const bestMatch = matches
            .filter(({ similarity }) => similarity > 0.97)
            .sort((a, b) => a.similarity - b.similarity)[0];

        if (bestMatch) return bestMatch.position;
        return undefined;
    }

    public async generateActionsList(
        emulatorClient: PromiseClient<typeof EmulatorController>,
        initialScreenshot: Image,
        triggerData: ILocationBasedTrigger
    ): Promise<BaseAction[]> {
        class DriveElevatorAction extends BaseAction {
            public override async do(): Promise<boolean> {
                const resourceScale = calculateResourceScale(initialScreenshot.width, initialScreenshot.height);

                // Get the numbers library ready for optical character recognition.
                const numbersLibrary = prepDictionaryToLibrary(
                    numericalImagesDictionary,
                    resourceScale,
                    (image: Image) => negateImage(image)
                );

                // Get a second screenshot with the floor number in it, crop it, and ocr the floor number
                const secondScreenshot = await getScreenshot(emulatorClient);
                const desiredFloorCropRegion: ICropRegion = { left: 130, width: 40, top: 1240, height: 50 };
                const secondScreenshotCropped = cropImage(secondScreenshot, desiredFloorCropRegion);
                const floor = Number(detectSequence(secondScreenshotCropped, numbersLibrary).sequence);

                // Send approximate elevator controls
                await new ClickAction(emulatorClient, {
                    x: secondScreenshot.width / 4,
                    y: secondScreenshot.height / 2,
                    timeout: (floor - 1) * 1000,
                }).do();

                // See if we got a costume or pet from this bitizen
                const thirdScreenshot = await getScreenshot(emulatorClient);
                const continueLibrary = prepDictionaryToLibrary(continueTemplates, resourceScale);
                const message = detectSequence(thirdScreenshot, continueLibrary);
                if (message.sequence === "continue") {
                    const continueButton = message.matches[Math.floor(message.matches.length / 2)].position;
                    await new ClickAction(emulatorClient, continueButton).do();
                }

                return true;
            }
        }

        return [
            // Press the elevator note
            new ClickAction(emulatorClient, triggerData),
            // Drive the elevator
            new DriveElevatorAction(emulatorClient),
        ];
    }
}