안녕하세요!
오늘은 어제에 이어 진행중이 프로그램 요약 정리를 해보겠습니다!
그런데 이번엔 아쉽게도 제가 지금까지 배운 내용들과 실력이 부족해서 완성을 다 하지 못했습니다.
제가 원했던 프로그램은 케릭터가 컴퓨터 사용자와 상호작용을 하는 것이였는데, 만들다 보니 그저 반응형 이미지 프로그램이 된 것 같습니다. 나중에 더 배워서 제가 원했던 프로그램으로 바꾸려고 해보겠습니다.
수정 사항 요약
- 실행 오류 해결 (type: "module" 충돌):
- package.json에 type: "module"이 있어 require 문법을 쓰는 Electron이 실행되지 않았습니다.
- 해결: Electron 실행 파일명을 electron.js에서 electron.cjs로 변경하여 CommonJS 모듈임을 명시했습니다.
- 바탕화면 투명화 (Electron 설정):
- 웹 브라우저가 아닌 Electron 앱으로 실행해야 투명 배경이 적용됩니다.
- 해결: transparent: true, frame: false 옵션을 추가하고 창 설정을 최적화했습니다.
- 좌우 반전 미적용 해결 시도:
- 애니메이션 이미지가 바뀔 때마다 스케일이 초기화되는 문제가 있었습니다.
- 해결 시도: 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 |