Spliterator의 소개 및 활용 (in JDK 1.8)

|

Overview

Spliterator 인터페이스를 접한건 Stream을 복제할 수 있는 방법이 없을까 고민하다 포함된 메서드들 중 spliterator()는 어떤건지 궁금해 찾아보게 됬습니다.
처음에 여기저기 블로그에 정리된 글을 찾아봤을땐 병렬처리를 지원하기 위해 Stream에 종속된 기능 정도로만 생각했는데, Oracle 공식 JDK 문서에는 특정 소스의 객체를 순회하거나 파티셔닝 하기 위한 인터페이스로 Stream의 파이프라이닝 처리를 위해 java.util.stream.Sink 클래스와 함께 핵심적인 역활을 맡고 있습니다. Stream외에도 배열/Collection/IOChannel 등 복수개의 개체를 지닌 모든 소스들에서 구현되거나 사용 할 수 있으며 생성 방법은 아래와 같습니다.

// Stream과 Collection의 경우 기본적으로 spliterator 메서드를 제공합니다.     
Spliterator<Integer> byStream = IntStream.range(0, 100).spliterator();    
Spliterator<Integer> byCollection = (new ArrayList<Integer>()).spliterator();    
// 배열의 경우 Spliterators 클래스를 통해 Spliterator 객체를 생성 할 수 있습니다.
Spliterator<Integer> byArray = Spliterators.spliterator(new int[]{1,2,3,4}, Spliterator.SORTED);

Methods

characteristics

Spliterator 객체의 특성에 대한 int값을 반환하는 메서드로 속성은 ORDERED, DISTINCT, SORTED, SIZED, NONNULL, IMMUTABLE, CONCURRENT, SUBSIZED 가 있으며 각 특성은 어떤 Spliterator 객체인가에 따라 다르며 그에 따른 각 메서드들의 내부적인 동작이 다를 수 있습니다.
예를 IntStream.of의 Spliterator의 경우 “IMMUTABLE, ORDERED, SIZED, SUBSIZED”의 특성을 가지고 있으나 IntStream.generate의 Spliterator의 경우 “IMMUTABLE”의 특성만을 지니고 있습니다. 또한 Set의 Spliterator의 경우 “DISTINCT, SIZED”의 특성을 가지고 있으며 List의 Spliterator의 경우 “ORDERED, SIZED, SUBSIZED”의 특성을 가집니다.

characteristics메서드의 반환값은 int형인데 Spliterator 객체에 포함된 모든 특성값의 합을 반환합니다.

// Set의 경우 DISTINCT=1, SIZED=64 의 합인 65를 반환합니다.
new HashSet().spliterator().characteristics();  // 65
// List의 경우 ORDERED=16, SIZED=64, SUBSIZED=16384 의 합인 16464를 반환합니다.
new ArrayList().spliterator().characteristics(); // 16464

Spliterator의 각 특성은 hasCharacteristics메서드를 통해 확인 할 수 있습니다.

estimateSize

순회할 개체의 사이즈를 알 수 있는 메서드로 SIZED/SUBSIZED 특성을 지녔으며 순회할 남은 개체의 사이즈를 반환해줍니다. 쉽게 말해 개수가 제한되어 있는 Stream의 사이즈를 알 수 있습니다.
Spliterator의 총 개체수가 아닌 순회 할 수 있는 개체의 개수이기 때문에 이미 순회한 개체의 수는 포함되지 않습니다.

@Test
public void estimateSize_test() {
    Spliterator<Integer> spliterator = IntStream.of(1,2,3,4,5).spliterator();
    System.out.println(spliterator.estimateSize()); // print 5
    spliterator.tryAdvance(t -> {});
    spliterator.tryAdvance(t -> {});
    spliterator.tryAdvance(t -> {});
    System.out.println(spliterator.estimateSize()); // print 2
}

tryAdvance

Spliterator의 요소가 남아 있을 경우 인자로 주어진 액션을 실행하고 요소의 존재 유무를 반환하는데, 특성에 ORDERED가 포함되어 있을 경우 순차적으로 요소를 제공하게 됩니다.
이 메서드의 주의할 점은 리턴값이 hasNext의 개념이 아니라 isExecuted의 개념이란 점입니다. 아래 예제를 보면 요소가 3개이나 3번째 호출까지 계속 true를 반환하는 것을 볼 수 있습니다.

@Test
public void tryAdvance_test() {
    Spliterator<Integer> spliterator = IntStream.of(1,2,3).spliterator();
    
    boolean r1 = spliterator.tryAdvance(System.out::println); // print 1
    boolean r2 = spliterator.tryAdvance(System.out::println); // print 2
    boolean r3 = spliterator.tryAdvance(System.out::println); // print 3
    boolean r4 = spliterator.tryAdvance(System.out::println); // not execute

    System.out.println(r1 + ", " + r2 + ", " + r3 + ", " + r4); // true, true, true, false
}

Spliterator 인터페이스에선 본 메서드를 사용하여 순회하는 forEachRemaining 메서드를 기본적으로 제공하며 코드는 아래와 같습니다.

default void forEachRemaining(Consumer<? super T> action) {
    do { } while (tryAdvance(action));
}

구현 메서드가 위와 같이 hasNext / isExecuted의 양쪽 개념을 다 포괄 할 수 있기 때문에 어느쪽이든 크게 상관없다 싶지만 가능하면 isExecuted 개념으로 구현하는 것을 권장합니다.

trySplit

Spliterator를 분할 하는 메서드로 원 객체에서 분할된 Spliterator를 반환하는데 ORDERED 특성일 경우 반환된 값이 앞부분이 되고 원 객체가 뒷부분이 됩니다.

@Test
public void trySplit_test() {
    Spliterator<Integer> origin = IntStream.range(0, 19).spliterator();
    Spliterator<Integer> dest = origin.trySplit();

    System.out.println(dest.estimateSize());  // size is 9
    dest.forEachRemaining(System.out::print); // print 0~8
    System.out.println();
    System.out.println(origin.estimateSize());  // size is 10
    origin.forEachRemaining(System.out::print);  // print 9 ~ 18
}

아쉽게도 분할할 사이즈나 범위는 제어 할 수 없도록 되어 있으며 절반으로 나뉘게 됩니다. 일반적인 Stream의 경우 parallel일 경우에만 분할이 가능하며 아닐 경우 분할한 Spliterator는 null로 반환합니다.

// java.util.stream.StreamSpliterators.AbstractWrappingSpliterator
@Override
public Spliterator<P_OUT> trySplit() {
    if (isParallel && !finished) {
        init();

        Spliterator<P_IN> split = spliterator.trySplit();
        return (split == null) ? null : wrap(split);
    }
    else
        return null;
}

Customize

Stream을 분할 할 수도 있고, size를 미리 알수 있으며, 흐름에 따라 일괄 처리되던 Stream을 단 건으로 실행 할 수 있는 등 이미 그 자체로도 충분히 활용할 곳이 많지만 Spliterator 자체를 커스텀하여 만들 경우 좀 더 다양하게 활용 가능합니다.
다음 Spliterator는 카운트를 지정하여 해당 카운트 만큼 Stream이 실행되면 pinned 메소드에 등록한 리스너를 실행합니다.

public class PinPointSpliterator<T> implements Spliterator<T> {
    private final Spliterator<T> spliterator;
    private final AtomicInteger counter = new AtomicInteger();
    private int pinCount = -1;
    private IntFunction<Boolean> listener = null;
    private boolean isListen = false;
    
    public PinPointSpliterator(Spliterator<T> spliterator) {
        this.spliterator = spliterator;
    }
    
    public PinPointSpliterator(Stream<T> stream) {
        this(stream.spliterator());
    }
    
    public static <T> PinPointSpliterator<T> newInstance(Stream<T> stream) {
        return new PinPointSpliterator<>(stream);
    }
    
    public PinPointSpliterator pinCount(int pinCount) {
        this.pinCount = pinCount;
        return this;
    }

    public PinPointSpliterator pinned(IntFunction<Boolean> listener) {
        this.isListen = listener != null;
        this.listener = listener;
        return this;
    }

    @Override
    public boolean tryAdvance(Consumer<? super T> action) {
        boolean hasNext = this.spliterator.tryAdvance(action);
        if (isListen && pinCount > 0 && counter.incrementAndGet() % pinCount == 0) {
            isListen = listener.apply(counter.get());
        }
        return hasNext;
    }

    @Override
    public Spliterator<T> trySplit() {
        return new PinPointSpliterator(this.spliterator.trySplit());
    }

    @Override
    public long estimateSize() {
        return this.spliterator.estimateSize();
    }

    @Override
    public int characteristics() {
        return this.spliterator.characteristics();
    }
}

실행은 아래와 같이 할 수 있습니다.

@Test
public void pinPointSpliterator_test() {
    List<Integer> buffer = new ArrayList<>();
    PinPointSpliterator<Integer> spliterator = PinPointSpliterator.newInstance(IntStream.range(0, 1000).boxed())
            .pinCount(100)
            .pinned(count -> {
                System.out.println("do listen. buffer size is " + buffer.size());
                buffer.clear();
                return count < 500;
            });
    StreamSupport.stream(spliterator, false)
        .peek(i -> buffer.add(i))
        .collect(Collectors.toList());

    System.out.println("final buffer size is " + buffer.size());
}

위 예제에서 100회 실행시마다 pinned에 설정된 리스너를 실행하는데 buffer의 사이즈를 출력하고 초기화하며, 500회보다 적게 실행된 경우에만 동작하므로 500회 이후엔 모두 buffer에 쌓이므로 500개가 쌓이게 됩니다. 최종 출력은 다음과 같습니다.

do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
do listen. buffer size is 100
final buffer size is 500

Conclusion

워낙 Stream에서 많은 기능을 제공하기에 잘 사용하지 않았었지만 Stream을 쓰다보면 무언가 조금씩 아쉬운 부분들이 존재했습니다. 예를 들어 1000개씩 분할해서 List로 담고 싶다던가, 특정 실행 횟수마다 어떤 동작을 한다던가, Stream의 전체 개수에 따라 다른 액션이 필요할 경우 등등 … 기존 Stream으로는 번거로운 작업들을 좀 더 쉽게 처리 할 수 있어 잘 쓰기만 한다면 많이 활용 할 수 있을것 같습니다.

(SpringData) Spring Data Rest Repository Not Working

|

이슈

오랜만에 SpringBoot로 간단한 REST API 서버를 만들어 볼 일이 생겨서 Spring Initializr를 통해 아래 모듈을 추가하여 작업했습니다.

// build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-data-rest'
	implementation 'org.springframework.data:spring-data-rest-hal-browser'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
spring :
    datasource :
        url : jdbc:h2:mem:data
        driverClassName : org.h2.Driver
        username : sa
        password : 1234
    jpa :
        database-platform : org.hibernate.dialect.H2Dialect
        show-sql: true
        generate-ddl: true
    h2.console :
        enabled : true
        path : /h2-console
        settings :
            trace : false
            web-allow-others : false
    data:
        rest:
            base-path: /api
@Data
@Entity
@Table(name = "article")
public class Article implements Serializable {
    @Id
    @GeneratedValue
    private long articleNo;

    @NotNull
    private String title;

    @Lob
    @NotNull
    private String content;

    ...
}

@RepositoryRestResource
public interface ArticleDao extends CrudRepository<Article, Long> {
    List<Article> findById(@Param("articleNo") long articleNo);
}

@Configuration
@EnableJpaRepositories
public class JpaConfig {

}

@SpringBootApplication
public class DemoApplication {
	public static void main(String[] args) {
		SpringApplication.run(DemoApplication.class, args);
	}
}

워낙에 spring-data-rest는 별도 설정할게 없는지라 잘 되겠지 하고 bootRun!! 1.5.X 버전때는 맵핑되는 Request 주소들이 기동시 로그에 다 찍혔는데 2.x 버전에서는 기본적으로 찍히지가 않았습니다. (그냥 그려려니…)

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v2.1.9.RELEASE)

2019-10-13 23:54:49.092  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : Starting DemoApplication on documents.ct.infn.it with PID 9085 (/Users/jistol/Downloads/demo/out/production/classes started by jistol in /Users/jistol/Downloads/demo)
2019-10-13 23:54:49.096  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : No active profile set, falling back to default profiles: default
2019-10-13 23:54:49.161  INFO 9085 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2019-10-13 23:54:49.161  INFO 9085 --- [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2019-10-13 23:54:49.979  INFO 9085 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data repositories in DEFAULT mode.
2019-10-13 23:54:50.006  INFO 9085 --- [  restartedMain] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 8ms. Found 0 repository interfaces.
2019-10-13 23:54:50.570  INFO 9085 --- [  restartedMain] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration' of type [org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration$$EnhancerBySpringCGLIB$$3c00740f] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2019-10-13 23:54:50.592  INFO 9085 --- [  restartedMain] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.hateoas.config.HateoasConfiguration' of type [org.springframework.hateoas.config.HateoasConfiguration$$EnhancerBySpringCGLIB$$bb80c141] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2019-10-13 23:54:51.019  INFO 9085 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port(s): 8099 (http)
2019-10-13 23:54:51.043  INFO 9085 --- [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2019-10-13 23:54:51.043  INFO 9085 --- [  restartedMain] org.apache.catalina.core.StandardEngine  : Starting Servlet engine: [Apache Tomcat/9.0.26]
2019-10-13 23:54:51.135  INFO 9085 --- [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2019-10-13 23:54:51.136  INFO 9085 --- [  restartedMain] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1975 ms
2019-10-13 23:54:51.436  INFO 9085 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2019-10-13 23:54:51.702  INFO 9085 --- [  restartedMain] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2019-10-13 23:54:51.779  INFO 9085 --- [  restartedMain] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [
	name: default
	...]
2019-10-13 23:54:51.890  INFO 9085 --- [  restartedMain] org.hibernate.Version                    : HHH000412: Hibernate Core {5.3.12.Final}
2019-10-13 23:54:51.892  INFO 9085 --- [  restartedMain] org.hibernate.cfg.Environment            : HHH000206: hibernate.properties not found
2019-10-13 23:54:52.125  INFO 9085 --- [  restartedMain] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {5.0.4.Final}
2019-10-13 23:54:52.323  INFO 9085 --- [  restartedMain] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
Hibernate: drop table article if exists
Hibernate: drop table post if exists
Hibernate: drop sequence if exists hibernate_sequence
Hibernate: create sequence hibernate_sequence start with 1 increment by 1
Hibernate: create table article (article_no bigint not null, content clob not null, created_by varchar(255) not null, created_date timestamp not null, title varchar(255) not null, update_by varchar(255) not null, updated_date timestamp not null, primary key (article_no))
Hibernate: create table post (post_no bigint not null, title varchar(255) not null, primary key (post_no))
2019-10-13 23:54:53.233  INFO 9085 --- [  restartedMain] o.h.t.schema.internal.SchemaCreatorImpl  : HHH000476: Executing import script 'org.hibernate.tool.schema.internal.exec.ScriptSourceInputNonExistentImpl@7f47a56f'
2019-10-13 23:54:53.237  INFO 9085 --- [  restartedMain] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2019-10-13 23:54:53.319  INFO 9085 --- [  restartedMain] o.s.b.d.a.OptionalLiveReloadServer       : LiveReload server is running on port 35729
2019-10-13 23:54:54.058  INFO 9085 --- [  restartedMain] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2019-10-13 23:54:54.120  WARN 9085 --- [  restartedMain] aWebConfiguration$JpaWebMvcConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2019-10-13 23:54:54.584  INFO 9085 --- [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8099 (http) with context path ''
2019-10-13 23:54:54.589  INFO 9085 --- [  restartedMain] com.example.demo.DemoApplication         : Started DemoApplication in 6.029 seconds (JVM running for 7.371)

그래도 H2 DB에 알아서 테이블이 잘 생성됬길래 Entity랑 Repository 설정은 다 잘 붙었나보다 하며 만들어진 API를 보기 위해 Hal-Browser로 접속했는데 profile 외에 아무것도 생성되지 않았습니다.

not working REST Repository

해결

원인은 패키지 구조에서 찾았는데 Configuration을 선언해둔 JpaConfig의 위치가 이슈였습니다.

wrong position

Entity 클래스는 com.example.demo.entity 하위에 위치하는데 @EnableJpaRepositories을 선언한 설정 클래스는 com.example.demo.config 하위에 위치해 있었던 거죠. JPA 설정은 알아서 잘 찾길래 딱히 basePackage 위치도 지정하지 않았는데 Rest Repository 설정엔 영향을 끼치는 것 같습니다. 사실 위 설정 클래스 자체가 없어도 @SpringBootApplication가 설정된 클래스 하위에 위치하면 알아서 스캔하기 때문에 JPA가 잘 동작하는데 말이죠.

/*  그냥 날려 버리거나 basePackages 설정을 추가합니다.
@Configuration
@EnableJpaRepositories(basePackages = "com.example.demo")
public class JpaConfig {

}
*/

resolve issue

위와 같이 Entity들의 CRUD Request Path가 잘 생성 된 것을 확인 할 수 있습니다.

(ES6) Canvas + Javascript로 웹 게임 만들기 - 이벤트 만들기

|

비게임 업종 서버개발자가 HTML5의 <canvas>와 Javascript(ES6)를 이용하여 취미로 개발해 본 웹게임에 대한 글로 개발 코드 자체에 대한 설명보다는 어떤 원리와 방식으로 개발하였는지에 대한 내용을 기술하고 있습니다.

순서 제목 링크
1 캐릭터 그리기 https://jistol.github.io/frontend/2019/09/25/create-webgame-1/
2 캐릭터 움직이기 https://jistol.github.io/frontend/2019/09/28/create-webgame-2/
3 이벤트 만들기 https://jistol.github.io/frontend/2019/09/29/create-webgame-3/

게임명은 “도마뱀플라이트”로 앱게임 “드래곤플라이트”를 모방하여 일부 기능을 따라 구현하였으며 실제 게임은 아래 링크에서 실행 해 볼 수 있습니다.

GAME : https://jistol.github.io/lizard/

SOURCE : https://github.com/jistol/lizard-flight

game capture

키 이벤트 만들기

캐릭터를 그렸으니 조정하는 방법을 추가 하도록 합니다. 전 포스팅에서도 언급했듯이 Worker내에서는 직접 DOM을 접근 할 수가 없습니다. 따라서 메인 페이지에서 키 이벤트를 받아 Worker에게 전달해야하는데, 이 때도 역시 postMessage를 사용하게 됩니다.

// main page
const worker = new Worker('worker.js');
const onKeyEvent = (e) => {
    worker.postMessage({ key : event.key });
};
document.addEventListener('keydown', onKeyEvent, false);

// worker.js
self.onmessage = function(e) {
  console.log(e.data.key);
}

이전 코드와 달라진 부분은 keydown 이벤트시 움직이는 방향으로 원을 이동시키고 keyup 이벤트시 움직임을 멈추도록 direct라는 변수를 추가하였으며 화면밖으로 원이 나가지 않도록 x의 크기를 제어하는 부분입니다.

터치 이벤트 만들기

요즘 대부분의 접속 환경이 모바일인 만큼 터치 이벤트를 이용한 움직임도 처리해보도록 하겠습니다. 키의 경우 명확하게 어떤 키를 누르고 땠는지 확인이 가능하나 터치는 같은 이벤트로 다른 처리를 해야하기에 키 이벤트와는 다르게 코딩 되어야 합니다.
터치 이벤트는 대표적으로 아래와 같이 존재합니다.

이벤트명 설명
touchstart 디바이스 화면에 손가락이 닿는 순간 발생하는 이벤트입니다.
touchmove 디바이스 화면에 손가락이 닿은 상태에서 손가락을 움직이면 발생하는 이벤트 입니다
touchend 디바이스 화면에 손가락이 닿은 상태에서 손가락을 떼어내면 발생하는 이벤트입니다.
touchcancel 터치 이벤트가 시스템으로 인해 취소 될 때 발생하는 이벤트입니다.

좀 더 자세한 설명은 Javascript Mobile Events의 이해를 참고하시기 바랍니다.
다시 구현으로 돌아가서, 캐릭터를 이동 시키기 위해서는 두가지 선택지가 있습니다. 첫번째로는 터치된 포인트 지점으로 캐릭터를 바로 이동시키는 방법과 두번째는 터치 후 움직이는 방향으로 이동시키는 방법입니다. 본 코드에서는 두번째 방법을 이용해 구현했습니다.

(ES6) Canvas + Javascript로 웹 게임 만들기 - 캐릭터 움직이기

|

비게임 업종 서버개발자가 HTML5의 <canvas>와 Javascript(ES6)를 이용하여 취미로 개발해 본 웹게임에 대한 글로 개발 코드 자체에 대한 설명보다는 어떤 원리와 방식으로 개발하였는지에 대한 내용을 기술하고 있습니다.

순서 제목 링크
1 캐릭터 그리기 https://jistol.github.io/frontend/2019/09/25/create-webgame-1/
2 캐릭터 움직이기 https://jistol.github.io/frontend/2019/09/28/create-webgame-2/
3 이벤트 만들기 https://jistol.github.io/frontend/2019/09/29/create-webgame-3/

게임명은 “도마뱀플라이트”로 앱게임 “드래곤플라이트”를 모방하여 일부 기능을 따라 구현하였으며 실제 게임은 아래 링크에서 실행 해 볼 수 있습니다.

GAME : https://jistol.github.io/lizard/

SOURCE : https://github.com/jistol/lizard-flight

game capture

setTimeout을 이용하여 캐릭터 움직이기

캐릭터를 움직이는 원리는 애니메이션의 원리와 같습니다. canvas에 캐릭터를 빠르게 다시 그려서 마치 위치가 바뀐것처럼 보이게 하는겁니다.
본 코드에서는 전체화면을 지우고 다시 그리는데 화면이 끊기거나 속도가 느려지지 않을까 하는 우려와는 달리 빠르게 처리되고 크게 이질감도 없었습니다.

화면을 다시 그리도록 반복하는 방법은 두가지가 있는데 그 중 가장 쉬운 방법은 setTimeout(또는 setInterval)을 이용하는 방법입니다.

위와 같이 코딩시에는 단점이 있습니다. setTimeout의 경우 해당 시간에 특정 함수를 동작시킬뿐 화면 프레임을 전혀 고려하지 않습니다.
브라우저가 화면을 그리기까지 몇 가지 단계가 있는데 그 단계를 모두 기다리지 못하거나 더 많은 텀을 가질 확률이 큽니다.
더 자세한 설명은 requestAnimationFrame() 개념 정리하기를 참고하세요.

requestAnimationFrame을 이용하여 캐릭터 움직이기

requestAnimationFrame 함수는 브라우저가 다음 리페인트를 수행하기 전에 해당 함수를 실행시켜 변경된 데이터로 그리도록 실행해주는 함수입니다.
자세한 설명은 MDN - window.requestAnimationFrame() 참고하시길 바라며 위 setTimeout을 이용한 코드에서 반복 부분만 requestAnimationFrame로 바꾼 코드입니다.

Web Worker를 이용하여 별도 스레드에서 그려보자

JavaScript는 기본적으로 단일 스레드 기반으로 동작합니다. 동시에 작업이 이뤄지는듯 보이지만 빠르게 하나씩 처리하거나 시분할하여 자원을 나눠쓰게 됩니다.
화면을 계속 갱신해야하는데 중간에 메인 스레드에 오래걸리는 작업을 실행하게 된다면 화면이 끊기거나 딜레이 될 수 있는데, 이 때 해결 할 수 있는 방법이 Web Worker를 이용하는 것입니다.

Web Worker는 메인 스레드가 아닌 별도 스레드를 이용하여 동작하기 때문에 메인 스레드의 부하에 영향없이 동작 할 수 있는 장점이 있으나, DOM 개체에 직접 접근 제어 할 수 없으며 메인 스레드와 message 이벤트를 통해서만 통신이 가능한 단점이 존재합니다. 추가로 new Worker()사용시 기본적으로 Dedicated Worker를 사용하게 되는데 이 때 모듈방식을 사용 할 수 없으며 import, export 방식 대신 importScripts() 함수를 이용하여 추가 가능합니다.
자세한 설명은 MDN - 웹 워커 사용하기을 참고하세요.

Worker는 직접 DOM 제어를 할 수 없기 때문에 canvas에 렌더링 하기 위해서는 메인 스레드로 부터 canvas를 전달 받아야 합니다. postMessage로 전달 가능한데 이 때 전달 가능한 객체는 Transferable인터페이스를 구현한 객체만 가능하며 나머지 객체는 오류를 발생 시킵니다. 관련한 사항은 MDN - Worker.postMessage() MDN - Transferable를 참고하세요.
다행히도 canvas는 위 인터페이스를 구현한 객체를 제공하는데 OffscreenCanvas객체로 HTMLCanvasElement.transferControlToOffscreen()함수를 통해 얻을 수 있습니다.
아직 모든 브라우저에서 제공하고 있진 않습니다.

(ES6) Canvas + Javascript로 웹 게임 만들기 - 캐릭터 그리기

|

비게임 업종 서버개발자가 HTML5의 <canvas>와 Javascript(ES6)를 이용하여 취미로 개발해 본 웹게임에 대한 글로 개발 코드 자체에 대한 설명보다는 어떤 원리와 방식으로 개발하였는지에 대한 내용을 기술하고 있습니다.

순서 제목 링크
1 캐릭터 그리기 https://jistol.github.io/frontend/2019/09/25/create-webgame-1/
2 캐릭터 움직이기 https://jistol.github.io/frontend/2019/09/28/create-webgame-2/
3 이벤트 만들기 https://jistol.github.io/frontend/2019/09/29/create-webgame-3/

게임명은 “도마뱀플라이트”로 앱게임 “드래곤플라이트”를 모방하여 일부 기능을 따라 구현하였으며 실제 게임은 아래 링크에서 실행 해 볼 수 있습니다.

GAME : https://jistol.github.io/lizard/

SOURCE : https://github.com/jistol/lizard-flight

game capture

canvas에 캐릭터 그리기

<canvas>에 그림을 그리기 위해서는 기본적으로 렌더링 컨텍스트를 노출하여 작업하게 됩니다. 본 게임은 2d만 사용하였으나 webgl을 이용한 3d도 그릴 수 있습니다.

let canvas = document.getElementById('canvas');
let context = canvas.getContext('2d');

위와 같이 컨텍스트를 노출하여 그리게 되는데 조종 할 게임 캐릭터를 그려보도록 하겠습니다.
(디자이너도 없고 하니 간단하게 원으로 생긴 캐릭터입니다.)

조종할 캐릭터를 그렸습니다. beginPath는 선을 그릴때 시작하는, closePath는 그리는 선을 닫아 시작점과 이어주는 역활을 합니다.
fill함수 사용시 열린 도형이 자동으로 닫히게 되어 closePath를 명시 할 필요가 없으나 코딩상 명확하게 열고 닫는것이 실수의 여지를 줄여줍니다.
arc함수를 사용하여 몸통,양쪽눈을 그렸습니다. arc함수에 대한 자세한 사용법은 MDN - CanvasRenderingContext2D.arc()를 참고하세요.

canvas 스케일 적용

위 예제에서 간단히 캐릭터를 그려보았습니다. 하지만 요즘 웹은 모바일 환경에서 많이 노출되며 각 폰마다 크기가 다르기 때문에 우리가 그린 캐릭터는 폰마다 다른 크기로 나올 수 있습니다. 이를 방지하기 위해 크기와 비율을 고정해 보도록 하겠습니다.
우선 모바일 디바이스 크기에 스케일을 맞추기 위해 HTML head에 아래와 같이 viewport를 추가합니다.

<html>
  <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
  </head>
  <body>
    <canvas id="mainCanvas"></canvas> 
    <script type="text/javascript">
      ...
    </script>
  </body>
</html>

viweport에 대한 자세한 설명은 모바일 화면을 위해 Viewport 사용하기 글을 참고하세요.
첫번째 예제에서는 canvas에 대한 width/height 값을 직접 입력했지만 화면 크기에 맞게 비율을 확대하려고 합니다.

// 전체 화면을 사용하기 위해 body의 속성을 정의해줍니다.
let body = document.body;
body.style.width = '100%';
body.style.height = '100%';
body.style.margin = '0';
body.style.padding = '0';

// canvas의 크기는 width=100%, height는 width의 1.5 비율로 사용할 예정입니다.
let canvas = document.getElementById('mainCanvas');
canvas.width = body.clientWidth;
canvas.height = Math.min(body.clientWidth * 1.5, body.clientHeight);
canvas.style.backgroundColor = '#000000';

// 실제 화면을 그릴 비율입니다.
// context를 이용하여 그림을 그릴 때 화면 넓이가 400, 높이는 넓이*1.5배라는 계산하에 작업할 예정입니다.
const rWidth = 400;
const rHeight = 400 * 1.5; // 600

// 실제 canvas 넓이와 그림 비율이 맞지 않기 때문에 scale을 변경해줍니다.
let context = canvas.getContext('2d');
let ratioX = canvas.width / rWidth;
let ratioY = canvas.height / rHeight;
context.scale(ratioX, ratioY);

위와 같이 화면 스케일을 자동으로 조절하게 만들어 두면 게임 화면을 그리기가 훨씬 수월해집니다.
디바이스 크기에 상관없이 화면 넓이가 400이란 전제하에 계산하여 캐릭터 크기를 조절 할 수 있게 됩니다.