살아있는 AI 고양이 프로젝트 만들어보기(2)

2025. 12. 7. 20:15·5. [개인] 프로젝트 및 공모전/4-2 바탕화면 AI 펫 프로그램

 

안녕하세요!

 

오늘은 어제에 이어 진행중이 프로그램 요약 정리를 해보겠습니다!

 

그런데 이번엔 아쉽게도 제가 지금까지 배운 내용들과 실력이 부족해서 완성을 다 하지 못했습니다.
제가 원했던 프로그램은 케릭터가 컴퓨터 사용자와 상호작용을 하는 것이였는데, 만들다 보니 그저 반응형 이미지 프로그램이 된 것 같습니다. 나중에 더 배워서 제가 원했던 프로그램으로 바꾸려고 해보겠습니다.


수정 사항 요약

  1. 실행 오류 해결 (type: "module" 충돌):
    • package.json에 type: "module"이 있어 require 문법을 쓰는 Electron이 실행되지 않았습니다.
    • 해결: Electron 실행 파일명을 electron.js에서 electron.cjs로 변경하여 CommonJS 모듈임을 명시했습니다.
  2. 바탕화면 투명화 (Electron 설정):
    • 웹 브라우저가 아닌 Electron 앱으로 실행해야 투명 배경이 적용됩니다.
    • 해결: transparent: true, frame: false 옵션을 추가하고 창 설정을 최적화했습니다.
  3. 좌우 반전 미적용 해결 시도:
    • 애니메이션 이미지가 바뀔 때마다 스케일이 초기화되는 문제가 있었습니다.
    • 해결 시도: beforeUpdate 루프의 가장 마지막 단계에서 강제로 xScale(좌우 반전)을 다시 적용하도록 순서를 바꿨습니다. => 다른 문제가 있는지 아직 해결하지 못했습니다.


1. package.json

  • 변경점: main 파일을 electron.cjs로 변경하고, 실행 스크립트도 이에 맞췄습니다.
{
  "name": "client",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "main": "electron.cjs",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview",
    "electron": "electron electron.cjs"
  },
  "dependencies": {
    "axios": "^1.13.2",
    "matter-js": "^0.20.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@eslint/js": "^9.39.1",
    "@types/react": "^19.2.5",
    "@types/react-dom": "^19.2.3",
    "@vitejs/plugin-react": "^5.1.1",
    "electron": "^39.2.5",
    "eslint": "^9.39.1",
    "eslint-plugin-react-hooks": "^7.0.1",
    "eslint-plugin-react-refresh": "^0.4.24",
    "globals": "^16.5.0",
    "vite": "^7.2.4"
  }
}

2. electron.cjs (파일명 변경 및 내용 수정)

  • 변경점: transparent: true 등 투명화 설정 완벽 적용.
const { app, BrowserWindow, ipcMain, screen } = require("electron");
const path = require("path");

function createWindow() {
  const { width, height } = screen.getPrimaryDisplay().workAreaSize;

  const mainWindow = new BrowserWindow({
    width: width,
    height: height,
    transparent: true,
    frame: false,
    hasShadow: false,
    alwaysOnTop: true,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
    },
  });

  mainWindow.setIgnoreMouseEvents(true, { forward: true });
  mainWindow.loadURL("http://localhost:5173");
}

ipcMain.on("set-ignore-mouse-events", (event, ignore, options) => {
  const win = BrowserWindow.fromWebContents(event.sender);
  win.setIgnoreMouseEvents(ignore, { forward: true });
});

app.whenReady().then(createWindow);

3. src/App.jsx (좌우 반전 로직 수정) - 수정 필요

  • 변경점: beforeUpdate 이벤트 내에서 텍스처(이미지) 변경 후, 맨 마지막 줄에서 xScale을 강제로 재설정하여 반전이 풀리는 현상을 막으려고 시도 했으나 실패했습니다.
import React, { useEffect, useRef, useState } from "react";
import Matter from "matter-js";
import axios from "axios";
import "./App.css";

const {
  Engine,
  Render,
  World,
  Bodies,
  Mouse,
  MouseConstraint,
  Runner,
  Events,
  Body,
} = Matter;

const App = () => {
  const sceneRef = useRef(null);
  const [serverStatus, setServerStatus] = useState("Conn...");
  const catRef = useRef(null);
  const latestCommand = useRef("idle");
  const textures = useRef({});

  const interactionState = useRef({
    isHit: false,
    isAttacking: false,
    currentAction: "idle",
    currentFrame: 1,
    lastUpdateTime: 0,
    facingLeft: false,
  });

  const animationConfig = {
    idle: { frames: 4, prefix: "idle", fps: 5, loop: true },
    walk: { frames: 5, prefix: "walk", fps: 10, loop: true },
    jump: { frames: 5, prefix: "jump", fps: 12, loop: true },
    hit: { frames: 4, prefix: "hit", fps: 12, loop: false },
    attack: { frames: 5, prefix: "attack", fps: 15, loop: false },
    sleep: { frames: 3, prefix: "die", fps: 3, loop: false },
  };

  useEffect(() => {
    const preloadImages = () => {
      Object.values(animationConfig).forEach((config) => {
        for (let i = 1; i <= config.frames; i++) {
          const img = new Image();
          const path = `/${config.prefix}${i}.png`;
          img.src = path;
          textures.current[path] = img;
        }
      });
    };
    preloadImages();
  }, []);

  useEffect(() => {
    const interval = setInterval(() => {
      if (!catRef.current) return;
      const cat = catRef.current;
      if (
        interactionState.current.isHit ||
        interactionState.current.isAttacking
      )
        return;

      axios
        .get("http://127.0.0.1:8000/ai-command", {
          params: { cat_x: cat.position.x, cat_y: cat.position.y },
        })
        .then((res) => {
          const { command, mood } = res.data;
          setServerStatus(`${mood} : ${command}`);
          latestCommand.current = command;

          if (command === "left") {
            Body.setVelocity(cat, { x: -2, y: cat.velocity.y });
            interactionState.current.facingLeft = true;
          } else if (command === "right") {
            Body.setVelocity(cat, { x: 2, y: cat.velocity.y });
            interactionState.current.facingLeft = false;
          } else if (command === "attack" || command === "sleep") {
            Body.setVelocity(cat, { x: 0, y: cat.velocity.y });
            if (command === "attack")
              interactionState.current.isAttacking = true;
          } else {
            Body.setVelocity(cat, { x: 0, y: cat.velocity.y });
          }
        })
        .catch(() => setServerStatus("AI Disconnected"));
    }, 50);
    return () => clearInterval(interval);
  }, []);

  useEffect(() => {
    const engine = Engine.create();
    const width = window.innerWidth;
    const height = window.innerHeight;

    const render = Render.create({
      element: sceneRef.current,
      engine: engine,
      options: {
        width: width,
        height: height,
        wireframes: false,
        background: "transparent",
      },
    });

    const cat = Bodies.rectangle(width / 2, 200, 80, 80, {
      inertia: Infinity,
      friction: 0,
      frictionAir: 0.05,
      render: { sprite: { texture: "/idle1.png", xScale: 0.5, yScale: 0.5 } },
    });
    catRef.current = cat;

    const wallOpts = { isStatic: true, render: { visible: false } };
    const ground = Bodies.rectangle(width / 2, height, width, 50, wallOpts);
    const left = Bodies.rectangle(0, height / 2, 50, height, wallOpts);
    const right = Bodies.rectangle(width, height / 2, 50, height, wallOpts);
    const top = Bodies.rectangle(width / 2, -50, width, 50, wallOpts);

    World.add(engine.world, [cat, ground, left, right, top]);

    const mouse = Mouse.create(render.canvas);
    const mouseConstraint = MouseConstraint.create(engine, {
      mouse: mouse,
      constraint: { stiffness: 0.2, render: { visible: false } },
    });
    World.add(engine.world, mouseConstraint);

    Events.on(mouseConstraint, "mousedown", (event) => {
      if (Matter.Bounds.contains(cat.bounds, event.mouse.position)) {
        if (!interactionState.current.isHit) {
          interactionState.current.isHit = true;
          interactionState.current.isAttacking = false;
          Body.setVelocity(cat, { x: 0, y: -5 });
        }
      }
    });

    Events.on(engine, "beforeUpdate", (event) => {
      const v = cat.velocity;
      const state = interactionState.current;
      const cmd = latestCommand.current;
      let nextAction = state.currentAction;

      if (state.isHit) nextAction = "hit";
      else if (state.isAttacking) nextAction = "attack";
      else if (cmd === "sleep") nextAction = "sleep";
      else {
        if (Math.abs(v.x) > 0.5) nextAction = "walk";
        else nextAction = "idle";
      }

      if (!state.isHit && !state.isAttacking && nextAction !== "sleep") {
        if (v.x < -0.1) state.facingLeft = true;
        else if (v.x > 0.1) state.facingLeft = false;
      }

      if (state.currentAction !== nextAction) {
        state.currentAction = nextAction;
        state.currentFrame = 1;
      }

      const config =
        animationConfig[state.currentAction] || animationConfig.idle;
      if (event.timestamp - state.lastUpdateTime >= 1000 / config.fps) {
        state.lastUpdateTime = event.timestamp;
        let next = state.currentFrame + 1;

        if (!config.loop && next > config.frames) {
          if (state.isHit) state.isHit = false;
          if (state.isAttacking) state.isAttacking = false;
          next = config.frames;
        } else {
          next = (state.currentFrame % config.frames) + 1;
        }
        state.currentFrame = next;

        const path = `/${config.prefix}${next}.png`;
        if (cat.render.sprite.texture !== path) {
          cat.render.sprite.texture = path;
        }
      }

      const baseScale = 0.5;
      cat.render.sprite.xScale = state.facingLeft ? -baseScale : baseScale;
      cat.render.sprite.yScale = baseScale;
    });

    if (window.require) {
      const { ipcRenderer } = window.require("electron");
      Events.on(mouseConstraint, "mousemove", (e) => {
        const hit = Matter.Bounds.contains(cat.bounds, e.mouse.position);
        ipcRenderer.send("set-ignore-mouse-events", !hit);
        render.canvas.style.cursor = hit ? "pointer" : "default";
      });
    }

    Runner.run(Runner.create(), engine);
    Render.run(render);

    return () => {
      Render.stop(render);
      World.clear(engine.world);
      Engine.clear(engine);
    };
  }, []);

  return (
    <div style={{ width: "100vw", height: "100vh", overflow: "hidden" }}>
      <div ref={sceneRef} />
      <div
        style={{
          position: "absolute",
          top: 0,
          left: 0,
          color: "white",
          textShadow: "1px 1px 2px black",
          pointerEvents: "none",
        }}
      >
        {serverStatus}
      </div>
    </div>
  );
};

export default App;

실행 방법 (터미널 3개 사용)

반드시 터미널 3개를 열어서 순서대로 실행하세요.

 

1. 터미널 1 (React):

client>npm run dev

2. 터미널 2 (Electron 앱):

client>npm run electron

3. 터미넗 3 (server):

server>uvicorn main:app --reload

'5. [개인] 프로젝트 및 공모전 > 4-2 바탕화면 AI 펫 프로그램' 카테고리의 다른 글

AI 바탕화면 펫 프로젝트 개선기 - 좌우 반전부터 실행 최적화까지  (0) 2025.12.15
살아있는 AI 고양이 프로젝트 만들어보기(3) - 배포(.exe)  (0) 2025.12.08
살아있는 AI 고양이 프로젝트 만들어보기(1)  (0) 2025.12.06
'5. [개인] 프로젝트 및 공모전/4-2 바탕화면 AI 펫 프로그램' 카테고리의 다른 글
  • AI 바탕화면 펫 프로젝트 개선기 - 좌우 반전부터 실행 최적화까지
  • 살아있는 AI 고양이 프로젝트 만들어보기(3) - 배포(.exe)
  • 살아있는 AI 고양이 프로젝트 만들어보기(1)
고니3000원
고니3000원
프로젝트의 구현 과정과 기술적 노하우를 담았습니다. AI 모델 연구와 매일의 학습 기록을 차곡차곡 공유하고 있습니다.
  • 고니3000원
    곤이의 공부 블로그
    고니3000원
  • 전체
    오늘
    어제
    • 분류 전체보기 (231)
      • 1. AI 논문 + 모델 분석 (22)
        • AI 논문 분석 (13)
        • AI 모델 분석 (9)
      • 2. 자료구조와 알고리즘 (16)
        • 2-1 자료구조와 알고리즘 (13)
        • 2-2 강화학습 알고리즘 (3)
      • 3. 자습 & 메모(실전, 실습, 프로젝트) (27)
        • 3-1 문제 해석 (4)
        • 3-2 메모(실전, 프로젝트) (14)
        • 3-3 배포 실전 공부 (7)
        • 3-4 최신 기술 분석 (2)
      • 4. [팀] 프로젝트 및 공모전 (31)
        • 4-1 팀 프로젝트(메모, 공부) (1)
        • 4-2 Meat-A-Eye (6)
        • 4-3 RL-Tycoon-Agent (3)
        • 4-4 구조물 안정성 물리 추론 AI 경진대회(D.. (4)
        • 4-5 AgentShield(보안 플랫폼) (17)
      • 5. [개인] 프로젝트 및 공모전 (25)
        • 4-1 귀멸의칼날디펜스(자바스크립트 활용) (5)
        • 4-2 바탕화면 AI 펫 프로그램 (4)
        • 4-3 개인 프로젝트(기타) (3)
        • 4-4 공모전 (9)
        • 4-5 나만의 로컬 LLM 멀티 에이전트 구축 (4)
      • 6. 경진 대회 (kaggle, dacon 등) (12)
        • 6-1 연구 및 개선 기록 (11)
        • 6-2 공유 코드 분석하기 (1)
      • 개념 정리 step1 (32)
        • Python 기초 (7)
        • DBMS (1)
        • HTML | CSS (3)
        • Git | GitHub (1)
        • JavaScript (5)
        • Node.js (5)
        • React (1)
        • 데이터 분석 (6)
        • Python Engineering (3)
      • 개념 정리 step2 (60)
        • Machine | Deep Learning (15)
        • 멀티모달(Multi-modal) (23)
        • 강화 학습 (10)
        • AI Agent (9)
        • 메디컬 이미지 (3)
      • 개인 공부 - 내가 공부하고 싶은 모든 것 (3)
        • 1. 인프라 (2)
        • 2. 자율주행 (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • Notion-포트폴리오
    • Github
  • 공지사항

    • 나의 핵심 가치
  • 인기 글

  • 태그

    강화학습
    github
    Ai
    파이썬
    pandas
    알고리즘
    Lora
    학습
    paddleocr
    API
    자료구조
    Python
    Vision
    Ollama
    공모전
    논문 리뷰
    데이터분석
    OCR
    RAG
    파인튜닝
    ViT
    자바스크립트
    구현
    llm
    transformer
    Agent
    전처리
    연구
    인공지능
    프로젝트
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
고니3000원
살아있는 AI 고양이 프로젝트 만들어보기(2)
상단으로

티스토리툴바