Swing/GameClient, Server

GameClient, Server example

Soul-Learner 2016. 8. 28. 23:06

GameServer, GameClient 예제


자바를 공부할 때 중요하면서도 어렵게 생각되는 Thread, Network, IO Stream 등을 사용해보기 위해 간단한 슈팅게임을 작성해 보려고 한다.

아무리 간단한 게임 서버라 할지라도 다음과 같은 문제가 해결되어야만 다수의 이용자가 접속하여 테스트라도 해볼 수가 있다


다수의 이용자가 각자의 시스템에 접속하여 자신의 포탄을 발사하여 볼을 맞추는 게임

A, D키를 이용하여 좌우로 발사대를 이동하고 SPACE 키로 포탄을 발사한다

먼저 10회의 히트수를 기록하는 플레이어가 우승

synchronized블럭이 필요한 경우

게임 클라이언트 화면의 동기화 문제

상대방의 활동이나 점수를 플레이어가 알 수 있도록 클라이언트간의 정보 공유문제

우승자의 화면에는 우승을, 다른 플레이어의 화면에는 패배를 의미하는 이미지를 출력하기

네트워크 상에서 ObjectInputStream, ObjectOutputStream 사용시 발생하는 오류 해결

특히 OptionalDataException 오류의 발생환경과 그 해결책



게임화면



게임종료시 화면 (종료시 사용된 이미지는 아래에 첨부함)

win_lose.zip



GameServer.java

package org.kdea.swing.game;

import java.awt.geom.Point2D;
import java.io.*;
import java.net.*;
import java.util.*;

/**
 * 클라이언트가 접속하면 통신용 쓰레드와 모든 접속자에게 주기적으로 게임 데이터를 전달하는 쓰레드를 실행한다
 */
public class GameServer
{
	//접속된 사용자 정보
	static List<UserConnection> userList = new ArrayList<>();
	//모든 이용자의 화면에서 이동 중인 포탄의 좌표 리스트(모든 이용자에게 전송됨)
	static List<Point2D> bulletList = new LinkedList<>();
	//모든 이용자들의 히트 수를 맵에 저장하여 각 이용자들에게 전송할 때 사용함
	static Map<String,Integer> pointMap = new HashMap<>();
	
	public static void main(String[] args) 
	{
		try {
			ServerSocket ss = new ServerSocket(1234);
			ServerLoopThread loop = null;
			UserConnection user = null;
			while(true){
				System.out.println("서버 대기 중...");
				Socket socket = ss.accept();
				//통신용 소켓을 이용하여 접속자와 서버가 통신하기 위한 스트림을 생성함
				user = new UserConnection(socket);
				//이용자로부터 입력된 이벤트 정보와 게임 데이터를 수신하는 쓰레드
				new UserThread(user).start();
				//모든 이용자에게 브로드캐스팅할 때 사용될 이용자정보 리스트
				userList.add(user);
				if(loop==null) {
					//모든 접속자에게 주기적으로 볼의 위치와 상대방의 화면 정보를 전송한다
					//서버측에서 클라이언트에게 출력하는 기능
					 loop = new ServerLoopThread();
					 loop.start();
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

/**볼의 좌표를 변경하고 이용자의 게임 데이터와 볼의 충돌여부를 검사하며
 * 모든 클라이언트에게 볼의 새좌표와 충돌여부를 주기적으로 송신하는 서버측 쓰레드 */
class ServerLoopThread extends Thread
{
	@Override
	public void run() {
		Ball ball = new Ball(1,50,1,0);

		System.out.println("서버 게임루프 시작...");
		UserConnection user = null;
		Point2D bulletPos = null;
		
		long prevTime = 0;
		long currTime = 0;
		//서버측 게임 루프(모든 이용자에게 현재 볼의 위치와 상대방의 포탄의 위치를 송신한다)
		while(true){
			//서버 루프 주기 확인용 테스트 코드
			currTime = System.currentTimeMillis();
			if(prevTime==0) prevTime = currTime;
			else{
				//System.out.printf("서버루프 주기:%d %n", currTime-prevTime);
				prevTime = currTime;
			}
			
			ball.move(); //볼의 좌표를 변경한다
			
			// 모든 이용자의 포탄좌표와 히트 수는 모든 다른 이용자의 화면에도 보여져야 하므로
			// 각 이용자의 이동 중인 포탄의 좌표와 히트 수를 리스트와 맵에 저장하여 모든 이용자에게 전달해야 한다
			// 포탄좌표 리스트와 히트수를 저장하는 맵에서 모든 원소를 삭제하고 다시 채운다
			GameServer.bulletList.clear();
			GameServer.pointMap.clear();
			//화면에서 이용 중인 모든 이용자들의 포탄좌표와 히트 수를 취합하여 리스트와 맵을 새로 채운다
			String winner = null;
			for(int i=0;i<GameServer.userList.size();i++){
				user = GameServer.userList.get(i);
				//각 이용자의 히트수를 취합하여 pointMap에 저장한다
				GameServer.pointMap.put(user.id, user.gd.hitCnt);
				if(user.gd.fired){
					//각 이용자의 이동 중인 포탄좌표를 취합하여 리스트에 저장한다
					GameServer.bulletList.add(user.gd.bulletPos);
				}
				if(user.gd.winner!=null) winner = user.gd.winner;
			}

			//볼의 위치, 포탄의 위치 등을 모든 이용자에게 브로드캐스트
			try {
				for(int i=0;i<GameServer.userList.size();i++) {
					user = GameServer.userList.get(i);
					user.gd.ballPos.setLocation(ball.x, ball.y); // 현재 볼의 위치 저장
					user.gd.bulletList = GameServer.bulletList;
					user.gd.pointMap = GameServer.pointMap;
					if(winner!=null) user.gd.winner = winner;
					if(user.gd.fired){ //발사된 포탄이 있다면 포탄의 위치를 변경하여 이동하게 한다
						bulletPos = user.gd.bulletPos;
						bulletPos.setLocation(bulletPos.getX(), bulletPos.getY()-3);
					}
					// 모든 접속자에게 게임화면의 정보를 송신한다
					while(true){
						try{
							user.out.reset();//직렬화 도중에 리셋을 하면 오류(stream active)가 발생함
							break;
						}catch(IOException ioe){}
					}
					user.out.writeObject(user.gd);
					user.out.flush();
				}
				
				Thread.sleep(33);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
}

/**접속자와 관련한 정보 및 접속자와 통신을 위한 스트림
 * 아이디, 소켓, 오브젝트 입력스트림, 오브젝트 출력스트림, 이용자의 게임 데이터
 * 여기서 생성된 입출력 스트림을 서버측에서 지속적으로 사용하며, 다른 곳에서는 스트림
 * 객체를 전혀 생성하지 않음
 */
class UserConnection 
{
	String id;
	Socket socket;
	ObjectInputStream oin;
	ObjectOutputStream out;
	//한 이용자가 서버로 전송한 게임 데이터를 저장
	GameData gd;

	public UserConnection(){}
	public UserConnection(Socket socket) {
		//이용자의 아이디를 접속시간으로 설정함
		this.id = String.valueOf(new Date().getTime());
		gd = new GameData(this.id);
		this.socket = socket;
		try {
			oin = new ObjectInputStream(socket.getInputStream());
			out = new ObjectOutputStream(socket.getOutputStream());
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

/** 이용자의 키보드, 마우스 입력 정보를 서버에서 수신하는 쓰레드 
 * 이용자로부터 게임  수신
 */
class UserThread extends Thread
{
	//이용자로부터 서버로 전송된 게임 데이터는 해당 이용자의 서버측 user.gd 변수에 저장되며, 
	//user의 참조는 GameServer.userList에도 저장되어 있으므로 서버 게임루프를 통해
	//다른 이용자에게 현재 이용자의 게임 데이터를 전달할 때 사용된다
	UserConnection user;
	
	UserThread(UserConnection user){
		this.user = user;
	}
	
	@Override
	public void run() {
		while(true){
			try {
				//이용자로부터 게임 데이터를 수신하여 해당 이용자의 GameData 참조변수에 저장
				user.gd = (GameData)user.oin.readObject();
			} catch (Exception e) {
				//e.printStackTrace();
				System.err.println("클라이언트 오류(퇴장)\n");
				if(e instanceof OptionalDataException){
					OptionalDataException ode = (OptionalDataException)e;
					System.out.printf("e.length:%d, e.eof:%b %n", ode.length, ode.eof);
				}
				GameServer.userList.remove(user);
				break;
			}
		}
	}
}

/**
 * 클라이언트와 서버 사이에 오가는 데이터를 캡슐화함
 * 다른 데이터가 더 필요하다면 여기 추가적으로 선언하면 됨
 */
class GameData implements Serializable
{
	String id; // 이용자 아이디
	//서버측에서 운용 볼의 위치
	Point2D ballPos = new Point2D.Double(); 
	//이용자가 키보드로 조종하는 발사대의 위치
	Point2D gunPos = new Point2D.Double(-100,-100); 
	// 다른 플레이어들이 발사한 포탄의 현재위치를 저장할 리스트
	List<Point2D> bulletList = new LinkedList<>(); 
	// 나의 포탄 위치
	Point2D bulletPos = new Point2D.Double();
	boolean fired; //발사하여 이동중일 때 true, 그 외에는 false
	boolean hit;	// 볼에 포탄이 명중하면 true
	boolean win;	// 플레이어가 우승하면 true
	String winner;	// 우승자 아이디
	int hitCnt;	//명중한 포탄의 수
	//모든 이용자의 히트수를 저장할 맵(이용자 아이디, 히트수를 쌍으로 저장함)
	Map<String,Integer> pointMap = new HashMap<>();
	
	public GameData(){}
	public GameData(String userId){
		this.id = userId;
	}
}


/** 모든 접속자의 화면에서 좌우로 무한히 이동하는 볼
 *  서버측에서 볼의 좌표를 변경하고 모든 접속자에게 그 좌표를 전송하면 
 *  클라이언트는 그 좌표를 받아서 화면에 그린다.
 */
class Ball
{
	double x,y;
	double xSpeed, ySpeed;
	
	Ball(){}
	Ball(double x, double y, double xSpeed, double ySpeed)
	{
		this.x = x;
		this.y = y;
		this.xSpeed = xSpeed;
		this.ySpeed = ySpeed;
	}
	
	void move() 
	{
		x += xSpeed;
		if(x>=400) {
			x=400;
			xSpeed = -xSpeed;
		}
		else if(x<=0) {
			x=0;
			xSpeed = -xSpeed;
		}
	}
}



GameClient.java

package org.kdea.swing.game;

import java.awt.*;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.geom.Point2D;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.*;
import java.util.Iterator;

import javax.imageio.ImageIO;
import javax.swing.*;
/**
 * 이 클래스는 클라이언트 게임 화면을 제공한다
 * 이용자의 키보드 입력정보 등 게임 데이터를 서버로 전송할 ObjectOutputStream을 사용한다
 * ObjectOutputStream을 사용하여 서버로 전송할 객체를 직렬화할 때 주의할 점이 있다
 * 객체를 전송하기 위해 직렬화하는 도중에 객체의 내용을 변경할 경우 서버측에서 OptionalDataException
 * 이 발생하면서 이후에 스트림을 사용할 수 없게 된다. 이런 이유로 발생한 OptionalDataException의
 * eof 속성값은 true 로 설정되므로 오류를 분석할 때 참고하면 된다
 * 전송할 객체의 내용을 변경하는 도중에는 직렬화 작업이 진행되지 않도록 해당 부분에 락을 설정해야 한다
 * 아래의 코드에서 synchronized 블럭을 사용한 것은 이와 같은 이유 때문이다
 */
public class GameClient extends JFrame
{
	GamePanel gp;
	GameData gd; //서버와 게임 데이터를 주고 받을 때 사용할 데이터 포맷
	ObjectOutputStream out;
	ObjectInputStream oin;
	boolean loop = true; // 클라이언트 측의 게임루프 실행/종료 결정
	
	public GameClient()
	{
		super("게임 클라이언트");
		setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
		setBounds(100,50,470,470);
		getContentPane().setLayout(null);
		
		gp = new GamePanel(this);
		add(gp);
		
		connect();//서버에 접속한다
		
		setVisible(true);
	}

	/** 서버연결에 성공하면 입출력 스림을 생성하고 입력 쓰레드를 실행한다*/
	private void connect() {
		try {
			Socket socket = new Socket("localhost",1234);
			out = new ObjectOutputStream(socket.getOutputStream());
			oin = new ObjectInputStream(socket.getInputStream());
			//서버로부터 게임 데이터를 수신하는 쓰레드
			new Thread(){
				@Override
				public void run() {
					Point2D ballPos = null;
					Point2D bulletPos = null;
					// 클라이언트 게임루프(서버측에서 주기적으로 게임 데이터를 전송할 때마다 1회의 루프가 실행된다) 
					// 서버측에서는 매 33밀리초마다 접속자에게 게임 데이터를 송출함
					while(loop){

						try {
							//서버측에서 전송된 게임 데이터를 수신함
							gd = (GameData)oin.readObject();
							
							//발사대의 위치 좌표를 게임 패널의 중앙 하단으로 초기화한다
							if(gd.gunPos.getX()==-100 && gd.gunPos.getY()==-100){
								synchronized(gp){
									gd.gunPos.setLocation(gp.x, gp.y);
								}
							}
							//화면을 그린다
							gp.repaint();
							
							if(gd.winner!=null) break;
							//화면을 그린 후의 처리
							// 포탄이 화면 상단 영역에 도달한 경우
							if(gd.bulletPos.getY()<0) {
								synchronized(gp){
									gd.fired = false;
								}
								gp.sendGameData();
							}
							//이용자가 발사한 포탄과 볼의 충돌검사
							if(gd.fired){
								ballPos = gd.ballPos;
								bulletPos = gd.bulletPos;
								double xDist = (ballPos.getX()+15)-(bulletPos.getX()+5);
								double yDist = (ballPos.getY()+15)-(bulletPos.getY()+5);
								if((xDist*xDist + yDist*yDist) <= (20*20)){
									synchronized(gp){
										gd.fired = false;
										gd.hit = true;
										gd.hitCnt++;
										if(gd.hitCnt==10) {
											gd.win = true;
											gd.winner = gd.id;
										}
										gp.sendGameData();
									}
								}
							}
							else if(gd.hit){
								synchronized(gp){
									gd.hit = false;
								}
								gp.sendGameData();
							}
						} catch (Exception e) {
							e.printStackTrace();
						}
					} // end of client loop
				}
			}.start();
		} catch (Exception e) {
			e.printStackTrace();
			System.exit(0);
		}
	}

	public static void main(String[] args) 
	{
		EventQueue.invokeLater(new Runnable() {
			@Override
			public void run() {
				new GameClient();
			}
		});
	}
}

class GamePanel extends JPanel
{
	GameClient f;
	BufferedImage ballRed, ballOrange, gun, bulletRed, bulletWhite;
	int x,y; //gun 의 초기위치
	
	GamePanel(GameClient f){
		this.f = f;
		//게임에 사용될 몇가지 도형을 BufferedImage에 그린다
		ballRed = new BufferedImage(30,30, BufferedImage.TYPE_INT_ARGB);
		Graphics2D g2d = (Graphics2D)ballRed.getGraphics();
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2d.setColor(Color.RED);
		g2d.fillOval(0, 0, 30, 30);
		g2d.dispose();
		
		ballOrange = new BufferedImage(30,30, BufferedImage.TYPE_INT_ARGB);
		g2d = (Graphics2D)ballOrange.getGraphics();
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2d.setColor(Color.ORANGE);
		g2d.fillOval(0, 0, 30, 30);
		g2d.dispose();
		
		gun = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB);
		g2d = (Graphics2D)gun.getGraphics();
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2d.setColor(Color.BLACK);
		g2d.fillRect(0, 0, 10, 10);
		g2d.dispose();
		
		bulletRed = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB);
		g2d = (Graphics2D)bulletRed.getGraphics();
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2d.setColor(Color.RED);
		g2d.fillOval(0, 0, 10, 10);
		g2d.dispose();
		
		bulletWhite = new BufferedImage(10, 10, BufferedImage.TYPE_INT_ARGB);
		g2d = (Graphics2D)bulletWhite.getGraphics();
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
		g2d.setColor(Color.BLACK);
		g2d.drawOval(0, 0, 10, 10);
		g2d.dispose();
		
		setBackground(Color.WHITE);
		setBounds(10,10,430,400);
		setFocusable(true);
		requestFocus();
		
		x = getWidth()/2-5;
		y = getHeight()-10;

		addKeyListener(new KeyAdapter(){
			@Override
			public void keyPressed(KeyEvent e) {
				
				int keyCode = e.getKeyCode();
				if(keyCode==KeyEvent.VK_A){
					if(f.gd.gunPos.getX()<=0) return;
					synchronized(f.gd){
						f.gd.gunPos.setLocation(f.gd.gunPos.getX()-4, f.gd.gunPos.getY());
					}
				}else if(keyCode==KeyEvent.VK_D){
					if(f.gd.gunPos.getX()>=getWidth()-10) return;
					synchronized(f.gd){
						f.gd.gunPos.setLocation(f.gd.gunPos.getX()+4, f.gd.gunPos.getY());
					}
				}else if(keyCode==KeyEvent.VK_SPACE){
					if(f.gd.fired) { return; }
					else {
						synchronized(f.gd){
							f.gd.fired = true;
						}
					}
					Point2D gunPos = f.gd.gunPos;
					Point2D myBulletPos = f.gd.bulletPos;
					synchronized(f.gd){
						myBulletPos.setLocation(gunPos.getX(), gunPos.getY()-10);//포탄 발사 위치 설정
					}
				}
				sendGameData();
			}
		});
	}
	
	@Override
	protected void paintComponent(Graphics g) {
		super.paintComponent(g);

		Graphics2D g2d = (Graphics2D) g;
		g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);

		if(f.gd==null) return;
		
		BufferedImage ball = f.gd.hit ? ballRed : ballOrange;

		//볼 그림
		g2d.drawImage(ball,(int)f.gd.ballPos.getX(), (int)f.gd.ballPos.getY(), null);
		
		//발사대 초기화
		if(f.gd.gunPos.getX()==-100 && f.gd.gunPos.getY()==-100){
			synchronized(this){
				f.gd.gunPos.setLocation(x, y);
			}
		}
		
		//발사대 그림
		g2d.drawImage(gun, (int)f.gd.gunPos.getX(), (int)f.gd.gunPos.getY(), null);
		
		//나의 포탄 그림
		if(f.gd.fired){
			Point2D bulletPos = f.gd.bulletPos;
			g2d.drawImage(bulletRed, (int)bulletPos.getX(), (int)bulletPos.getY(), null);
		}
		
		// 다른 플레이어의 포탄을 그린다
		for(int i=0;i<f.gd.bulletList.size();i++){
			Point2D otherBulletPos = f.gd.bulletList.get(i);
			//리스트에 포함된 정보가 나의 포탄의 위치일 경우에는 그리지 않는다(위에서 그렸기 때문에)
			if(f.gd.bulletPos.equals(otherBulletPos)) continue;
			g2d.drawImage(bulletWhite, (int)otherBulletPos.getX(), (int)otherBulletPos.getY(), null);
		}
		
		//나의 포탄이 볼에 명중한 횟수(나의 점수) 출력
		g2d.drawString("HIT:"+f.gd.hitCnt, 5, 25);
		
		//다른 이용자의 히트수 출력
		synchronized(this){
			f.gd.pointMap.remove(f.gd.id); // 나의 히트 수는 맵에서 제거한다(위에서 출력했으므로)
		}
		String[] id = f.gd.pointMap.keySet().toArray(new String[f.gd.pointMap.size()]);
		for(int i=0;i<id.length;i++){
			//다른 이용자의 점수를 출력하는 위치에 나의 점수는 출력하지 않는다
			g2d.drawString(id[i]+" HIT:"+f.gd.pointMap.get(id[i]), 300, 15*(i+1));
		}
		
		//히트 수 10개에 가장 먼저 도달한 경우 우승한다
		if(f.gd.win || f.gd.winner!=null){ //누군가가 우승한 경우
			String fileName = f.gd.win ? "win.png" : "lose.png";
			InputStream is = getClass().getResourceAsStream(fileName);
			try {
				BufferedImage winImg = ImageIO.read(is);
				int x = getWidth()/2-(winImg.getWidth()/2);
				int y = getHeight()/2-(winImg.getHeight()/2);
				g2d.drawImage(winImg, x, y, null);
			} catch (IOException e) {
				e.printStackTrace();
			}
			f.loop = false;
			return;
		}
	}
	
	long prevTime,currTime;
	public void sendGameData(){
		/*if(prevTime==0) prevTime = System.currentTimeMillis();
		else {
			currTime = System.currentTimeMillis();
			if(prevTime+waitTime>currTime) return;
			else prevTime = currTime;
		}*/
		try {
			while(true){
				try{
					f.out.reset(); //직렬화 도중에 호출되면 오류발생
					break;
				}catch(Exception ex){ex.printStackTrace();}
			}
			synchronized(this){
				f.out.writeObject(f.gd);
			}
			f.out.flush();
		} catch (IOException e1) {
			e1.printStackTrace();
		}
	}
}