고양이를 너무 좋아하는 나는 이번 주말에 Canvas로 언덕을 뛰어다니는 고양이를 구현해보려고 한다.
Canvas 초기 세팅
index.html
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cat's on the hill</title>
<link rel="stylesheet" type="text/css" href="./style.css" >
</head>
<body>
<script type="module" src="./app.js"></script>
</body>
</html>
style.css
* {
outline: 0;
margin: 0;
padding: 0;
}
html {
width: 100%;
height: 100%;
}
body {
width: 100%;
height: 100%;
background-color: #C2DEDC; // 내가 좋아하는 색상
}
canvas {
width: 100%;
height: 100%;
}
app.js
class App {
constructor() {
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
document.body.appendChild(this.canvas);
window.addEventListener("resize", this.resize.bind(this), false);
this.resize();
requestAnimationFrame(this.animate.bind(this));
}
// 스크린의 넓이, 높이를 가져오기 위해 resize()추가
resize() {
this.stageWidth = document.body.clientWidth;
this.stageHeight = document.body.clientHeight;
this.canvas.width = this.stageWidth * 2;
this.canvas.height = this.stageHeight * 2;
this.ctx.scale(2, 2);
this.catController.resize(this.stageWidth, this.stageHeight);
}
animate(t) {
requestAnimationFrame(this.animate.bind(this));
// 최초에 canvas를 깨끗이 지워줌
this.ctx.clearRect(0, 0, this.stageWidth, this.stageHeight);
}
}
window.onload = () => {
new App();
};
움직이는 언덕 만들기
이렇게 기초 세팅을 끝내놓고 언덕을 그리러 가보자
hill.js
export class Hill {
// 생성자 함수
// color : Hill 색상
// speed : Hill 움직임의 속도
// total : 포인트의 총 개수
constructor(color, speed, total) {
this.color = color;
this.speed = speed;
this.total = total;
}
// 크기 조정 함수
resize(stageWidth, stageHeight) {
// 화면의 너비와 높이를 설정
this.stageWidth = stageWidth;
this.stageHeight = stageHeight;
// 포인트 배열 초기화
this.points = [];
// 각 포인트 사이의 간격을 설정. 전체 너비를 포인트 개수만큼 나누어 간격을 설정
this.gap = Math.ceil(this.stageWidth / (this.total - 2));
// 각 포인트의 x, y 좌표를 설정하며 포인트 배열에 추가
for (let i = 0; i < this.total; i++) {
this.points[i] = {
x: i * this.gap,
y: this.getY(),
};
}
}
// 그리기 함수
draw(ctx) {
// 캔버스 색상을 Hill 색상으로 설정
ctx.fillStyle = this.color;
ctx.beginPath();
let cur = this.points[0];
let prev = cur;
// dots 배열을 이용해서 곡선의 컨트롤 포인트를 저장
let dots = [];
cur.x += this.speed;
// 화면을 벗어나면 포인트 배열에서 제거하고 새로운 포인트를 추가
if (cur.x > -this.gap) {
this.points.unshift({
x: -(this.gap * 2),
y: this.getY(),
});
} else if (cur.x > this.stageWidth + this.gap) {
this.points.splice(-1);
}
ctx.moveTo(cur.x, cur.y);
let prevCx = cur.x;
let prevCy = cur.y;
// 각 포인트를 순회하며 이전 포인트와 현재 포인트를 이용해 곡선을 그림
for (let i = 1; i < this.points.length; i++) {
cur = this.points[i];
cur.x += this.speed;
const cx = (prev.x + cur.x) / 2;
const cy = (prev.y + cur.y) / 2;
ctx.quadraticCurveTo(prev.x, prev.y, cx, cy);
dots.push({
x1: prevCx,
y1: prevCy,
x2: prev.x,
y2: prev.y,
x3: cx,
y3: cy,
});
prev = cur;
prevCx = cx;
prevCy = cy;
}
// 곡선 그리기를 완료하고 캔버스 아래로 라인을 그림
ctx.lineTo(prev.x, prev.y);
ctx.lineTo(this.stageWidth, this.stageHeight);
ctx.lineTo(this.points[0].x, this.stageHeight);
ctx.fill();
// 곡선의 컨트롤 포인트를 반환
return dots;
}
// 랜덤한 높이를 계산하는 함수
getY() {
const min = this.stageHeight / 8;
const max = this.stageHeight - min;
// min과 max 사이의 랜덤 값을 반환
return min + Math.random() * max;
}
}
app.js에 언덕을 3개 추가해 줬다.
그리고 각각 언덕에 대해서 속도를 가까운 곳부터 먼 곳 순서로 느리게 움직이게 해서 원근감을 표현했다
app.js
import { Hill } from "./hill.js";
class App {
constructor() {
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
document.body.appendChild(this.canvas);
// Hill 객체들을 배열로 관리
this.hills = [
new Hill("#ECE5C7", 0.2, 12),
new Hill("#CDC2AE", 0.5, 8),
new Hill("#116A7B", 1.4, 6),
];
window.addEventListener("resize", this.resize.bind(this), false);
this.resize();
requestAnimationFrame(this.animate.bind(this));
}
resize() {
this.stageWidth = document.body.clientWidth;
this.stageHeight = document.body.clientHeight;
this.canvas.width = this.stageWidth * 2;
this.canvas.height = this.stageHeight * 2;
this.ctx.scale(2, 2);
// 각 Hill 객체의 크기를 조정
for (let i = 0; i < this.hills.length; i++) {
this.hills[i].resize(this.stageWidth, this.stageHeight);
}
}
animate(t) {
requestAnimationFrame(this.animate.bind(this));
this.ctx.clearRect(0, 0, this.stageWidth, this.stageHeight);
let dots;
// 각 Hill 객체를 그림
for (let i = 0; i < this.hills.length; i++) {
dots = this.hills[i].draw(this.ctx);
}
}
}
// 윈도우 로드 이벤트가 발생하면 App 객체를 생성
window.onload = () => {
new App();
};
고양이 생성
고양이 파일 10개를 준비해 놨지만 온라인에서 제공하는 시퀀스 파일은 배경이미지를 흰색으로 넣어버려 가지고 파이썬 파일로 직접 구현했다.
from PIL import Image
import os
# 이미지 파일들이 위치한 디렉토리
img_dir = "/Users/blanc/Documents/Project/cats-on-hill/cats"
# 해당 디렉토리 내의 모든 PNG 파일을 가져옵니다.
imgs = [i for i in os.listdir(img_dir) if i.endswith(".png")]
# 파일 이름을 숫자로 정렬합니다. (예: "1.png", "2.png", ..., "10.png")
imgs = sorted(imgs, key=lambda x: int(os.path.splitext(x)[0]))
# 각각의 이미지를 연다.
img_list = [Image.open(os.path.join(img_dir, img)).convert("RGBA") for img in imgs]
# 각각의 이미지의 너비와 높이를 가져온다.
img_widths, img_heights = zip(*(i.size for i in img_list))
# 전체 이미지의 너비는 각각의 이미지의 너비의 합이고, 높이는 가장 큰 높이와 같다.
total_width = sum(img_widths)
max_height = max(img_heights)
# 새 이미지를 생성한다. (RGBA 모드로 생성하여 배경이 없는 이미지를 만든다)
new_img = Image.new('RGBA', (total_width, max_height))
# 각각의 이미지를 새 이미지에 붙인다.
x_offset = 0
for img in img_list:
new_img.paste(img, (x_offset,0))
x_offset += img.width
# 결과 이미지를 저장한다.
new_img.save('combined.png')
이 코드를 통해서 1장의 파일로 만들었다.
파일을 첨부해 놨으니 다운로드하고 싶은 사람은 언제든지 받아도 좋다.
고양이 파일은 다른 파일들과 마찬가지로 같은 폴더에 넣어두자 catController.js에서 쓰인다.
이제 고양이의 이미지는 준비되었으니까 고양이를 정의해 보자
cat.js
export class Cat {
constructor(img, stageWidth) {
// 생성자 함수에서 필요한 변수들을 초기화
this.img = img;
// 총 프레임과 현재 프레임
this.totalframe = 8;
this.curFrame = 0;
// 이미지의 너비와 높이
this.imgWidth = 112;
this.imgHeight = 72;
// 고양이의 너비와 높이
this.catWidth = 56;
this.catHeight = 36;
// 고양이 이미지의 너비의 절반
this.catWidthHalf = this.catWidth / 2;
// 초기 위치와 속도 설정
this.x = stageWidth + this.catWidth;
this.y = 0;
this.speed = Math.random() * 2 + 1;
// 1초에 5번 움직여서 한 동작으로 묶었다
this.fps = 5;
this.fpsTime = 1000 / this.fps;
}
// 그리기 함수
draw(ctx, t, dots) {
// 이전에 그렸던 시간과 현재 시간을 비교
if (!this.time) {
this.time = t;
}
const now = t - this.time;
if (now > this.fpsTime) {
this.time = t;
this.curFrame += 1;
// 모든 프레임을 그렸으면 첫 프레임으로 돌아감
if (this.curFrame == this.totalframe) {
this.curFrame = 0;
}
}
this.animate(ctx, dots);
}
// 애니메이션 함수
animate(ctx, dots) {
this.x -= this.speed * 0.4;
// 고양이의 y 위치를 구함
const closest = this.getY(this.x, dots);
this.y = closest.y;
// Canvas 상태 저장
ctx.save();
ctx.translate(this.x, this.y);
ctx.fillStyle = "#000000";
// 고양이의 회전 각도를 설정
ctx.rotate(closest.rotation);
// 고양이 이미지를 그림
ctx.drawImage(
this.img,
this.imgWidth * this.curFrame,
0,
this.imgWidth,
this.imgHeight,
-this.catWidthHalf,
-this.catHeight + 2,
this.catWidth,
this.catHeight
);
// Canvas 상태 복원
ctx.restore();
}
// 고양이의 y 위치와 회전 각도를 구하는 함수
getY(x, dots) {
for (let i = 1; i < dots.length; i++) {
if (x >= dots[i].x1 && x <= dots[i].x3) {
return this.getY2(x, dots[i]);
}
}
return {
y: 0,
rotation: 0,
};
}
// getY 함수의 보조 함수
getY2(x, dot) {
const total = 200;
let pt = this.getPointOnQuad(
dot.x1,
dot.y1,
dot.x2,
dot.y2,
dot.x3,
dot.y3,
0
);
let prevX = pt.x;
for (let i = 1; i < total; i++) {
const t = i / total;
pt = this.getPointOnQuad(
dot.x1,
dot.y1,
dot.x2,
dot.y2,
dot.x3,
dot.y3,
t
);
if (x >= prevX && x <= pt.x) {
return pt;
}
prevX = pt.x;
}
return pt;
}
// 언덕의 가파르기와 높낮이를 찾기 위해서 해당 함수를 정의한다. (stack overflow에 잘 나와있다)
getQuadValue(p0, p1, p2, t) {
return (1 - t) * (1 - t) * p0 + 2 * (1 - t) * t * p1 + t * t * p2;
}
// 언덕의 점을 구하는 함수
getPointOnQuad(x1, y1, x2, y2, x3, y3, t) {
const tx = this.quadTangent(x1, x2, x3, t);
const ty = this.quadTangent(y1, y2, y3, t);
const rotation = -Math.atan2(tx, ty) + (90 * Math.PI) / 180;
return {
x: this.getQuadValue(x1, x2, x3, t),
y: this.getQuadValue(y1, y2, y3, t),
rotation,
};
}
// 접선을 구하는 함수
quadTangent(a, b, c, t) {
return 2 * (1 - t) * (b - a) + 2 * (c - b) * t;
}
}
그리고 고양이를 소환하고 어떻게 움직이게 할지 정의해주는 catController도 만들자
catController.js
import { Cat } from "./cat.js";
export class CatController {
constructor() {
// 새 이미지 객체 생성
this.img = new Image();
// 이미지 로딩이 완료되면 loaded 함수 호출
this.img.onload = () => {
this.loaded();
};
// 이미지 경로 설정
this.img.src = "cats.png";
// Cat 객체를 담을 배열
this.items = [];
this.cur = 0;
this.isLoaded = false;
}
// 무대의 크기를 받아와 저장하는 함수
resize(stageWidth, stageHeight) {
this.stageWidth = stageWidth;
this.stageHeight = stageHeight;
}
// 이미지 로딩이 완료된 후 호출되는 함수
loaded() {
this.isLoaded = true;
this.addCat();
}
// Cat 객체를 생성하여 items 배열에 추가하는 함수
addCat() {
this.items.push(new Cat(this.img, this.stageWidth));
}
// Cat 객체들을 그리는 함수
draw(ctx, t, dots) {
// 이미지가 로딩된 경우에만 그리기 수행
if (this.isLoaded) {
this.cur += 1;
if (this.cur > 200) {
this.cur = 0;
this.addCat();
}
// 배열에 있는 Cat 객체들을 그리고 화면 밖으로 나간 객체는 제거
for (let i = this.items.length - 1; i >= 0; i--) {
const item = this.items[i];
if (item.x < -item.width) {
this.items.splice(i, 1);
} else {
item.draw(ctx, t, dots);
}
}
}
}
}
그리고 나서 app.js로 돌아와서 방금 정의한 스크립트들을 import 해주고 고양이를 생성하면,
app.js
import { Hill } from "./hill.js";
import { CatController } from "./cat-controller.js";
class App {
constructor() {
this.canvas = document.createElement("canvas");
this.ctx = this.canvas.getContext("2d");
document.body.appendChild(this.canvas);
this.hills = [
new Hill("#ECE5C7", 0.2, 12),
new Hill("#CDC2AE", 0.5, 8),
new Hill("#116A7B", 1.4, 6),
];
this.catController = new CatController();
window.addEventListener("resize", this.resize.bind(this), false);
this.resize();
requestAnimationFrame(this.animate.bind(this));
}
resize() {
this.stageWidth = document.body.clientWidth;
this.stageHeight = document.body.clientHeight;
this.canvas.width = this.stageWidth * 2;
this.canvas.height = this.stageHeight * 2;
this.ctx.scale(2, 2);
for (let i = 0; i < this.hills.length; i++) {
this.hills[i].resize(this.stageWidth, this.stageHeight);
}
this.catController.resize(this.stageWidth, this.stageHeight);
}
animate(t) {
requestAnimationFrame(this.animate.bind(this));
this.ctx.clearRect(0, 0, this.stageWidth, this.stageHeight);
let dots;
for (let i = 0; i < this.hills.length; i++) {
dots = this.hills[i].draw(this.ctx);
}
this.catController.draw(this.ctx, t, dots);
}
}
window.onload = () => {
new App();
};
완성했다.
이제 html파일을 실행하면 된다..!
사실 티스토리 블로그 커버이미지에 저 위의 코드를 삽입하려고 시작했었는데
막상 넣어보니 CPU소모가 커서 렉이 너무 심하게 걸렸다.. 어쩔 수 없이 뺐다.. ㄲㅂ
끝
'재미' 카테고리의 다른 글
크롬 익스텐션으로 사내툴 개선 (1) | 2023.11.02 |
---|---|
드디어 열린 취업의 문 (3) | 2023.10.17 |
ChatGPT를 이용한 자동화 블로그 (2) | 2023.04.29 |