From 1d9925c287b318ec21343e2682b51ab6a36ae8db Mon Sep 17 00:00:00 2001 From: Diego Roversi Date: Sun, 8 Sep 2019 18:12:27 +0200 Subject: initial commit from cvs 1.6.2 --- java/.cvsignore | 1 + java/Makefile | 2 + java/pclient.java | 1196 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1199 insertions(+) create mode 100644 java/.cvsignore create mode 100644 java/Makefile create mode 100644 java/pclient.java (limited to 'java') diff --git a/java/.cvsignore b/java/.cvsignore new file mode 100644 index 0000000..539da74 --- /dev/null +++ b/java/.cvsignore @@ -0,0 +1 @@ +*.py[co] diff --git a/java/Makefile b/java/Makefile new file mode 100644 index 0000000..a24bc68 --- /dev/null +++ b/java/Makefile @@ -0,0 +1,2 @@ +pclient.class: pclient.java + javac -target 1.1 pclient.java diff --git a/java/pclient.java b/java/pclient.java new file mode 100644 index 0000000..7e75536 --- /dev/null +++ b/java/pclient.java @@ -0,0 +1,1196 @@ +import java.applet.*; +import java.awt.*; +import java.awt.image.*; +import java.awt.event.*; +import java.io.*; +import java.net.*; +import java.util.*; +import java.util.zip.*; +import java.lang.*; + + +public class pclient extends Applet { + + // Utilities + + public static String[] splitString(String s, char delim) { + // StringTokenizer drops empty tokens :-( + int count = 1; + int length = s.length(); + for (int i=0; i 0) { + int count = st.read(buffer, off, len); + if (count <= 0) + throw new IOException("unexpected end of data"); + off += count; + len -= count; + } + } + + public static Color makeColor(int color) { + return new Color(color & 0xFF, + (color >> 8) & 0xFF, + (color >> 16) & 0xFF); + } + + public static InputStream decompresser(byte[] data, int off, int len) { + return new InflaterInputStream(new ByteArrayInputStream(data, off, len)); + } + + /*class ArraySlice { + public byte[] array; + public int ofs, size; + ArraySlice(byte[] aarray, int aofs, int asize) { + array = aarray; + ofs = aofs; + size = asize; + } + }*/ + + public void debug(Throwable e) { + showStatus(e.toString()); + e.printStackTrace(); + } + + // Bitmaps and icons + + class Bitmap { + + int w, h; + public int[] pixelData; + + Bitmap(pclient client, InputStream st, int keycol) throws IOException { + String line = readLine(st); + if (!"P6".equals(line)) throw new IOException("not a P6 PPM image"); + while ((line = readLine(st)).startsWith("#")) + ; + String[] wh = splitString(line, ' '); + if (wh.length != 2) throw new IOException("invalid PPM image size"); + w = Integer.parseInt(wh[0]); + h = Integer.parseInt(wh[1]); + line = readLine(st); + if (!"255".equals(line)) + throw new IOException("not a 255-levels PPM image"); + + // over-allocate an extra uninitialized line at the bottom of the + // image to work around a bug in the MemoryImageSource constructor + pixelData = new int[w*(h+1)]; + int target = 0; + int w3 = 3*w; + byte[] lineBuffer = new byte[w3]; + for (int y=0; y>>"); + if (data.length >= 4 && pongMessage.equals(data[0])) { + InetAddress result; + if (data[2].length() == 0) { + result = inp.getAddress(); + } + else { + result = InetAddress.getByName(data[2]); + } + port = Integer.parseInt(data[3]); + showStatus("Connecting to "+data[1]+" at "+ + result.toString()+":"+Integer.toString(port)+"..."); + return new Socket(result, port); + } + else + throw new IOException("got an unexpected answer from " + + inp.getAddress().toString()); + } + + // Game state + + class Player { + public int pid; + public boolean playing; + public boolean local; + public Image icon; + public int xmin, xmax; + } + + class KeyName { + public String keyname; + public int keyid; + public Image[] keyicons; + public KeyName next; + public int newkeycode; + } + + public Player[] players = new Player[0]; + public KeyName keys = null; + public Hashtable keycodes = new Hashtable(); + public boolean taskbarfree = false; + public boolean taskbarmode = false; + public KeyName keydefinition_k = null; + public int keydefinition_pid; + public Image[] iconImages = new Image[0]; + public Bitmap[] bitmaps = new Bitmap[0]; + + public Player getPlayer(int id) { + if (id >= players.length) { + Player[] newply = new Player[id+1]; + System.arraycopy(players, 0, newply, 0, players.length); + players = newply; + } + if (players[id] == null) { + players[id] = new Player(); + players[id].pid = id; + players[id].playing = false; + players[id].local = false; + } + return players[id]; + } + + public Player nextPlayer(Player prev) { + int i; + for (i=prev.pid+1; i= iconImages.length) + return null; + else + return iconImages[ico]; + } + + public void setIcon(int ico, Image img) { + if (ico >= iconImages.length) { + Image[] newico = new Image[ico+1]; + System.arraycopy(iconImages, 0, newico, 0, iconImages.length); + iconImages = newico; + } + iconImages[ico] = img; + } + + public void setBitmap(int n, Bitmap bmp) { + if (n >= bitmaps.length) { + Bitmap[] newbmp = new Bitmap[n+1]; + System.arraycopy(bitmaps, 0, newbmp, 0, bitmaps.length); + bitmaps = newbmp; + } + bitmaps[n] = bmp; + } + + // Sprites + + class Sprite { + public int x, y, ico; + public Image bkgnd; + + public final boolean draw(pclient client, Image backBuffer, + Graphics backGC) { + Image iconImage = client.getIcon(ico); + if (iconImage == null) { + ico = -1; + return false; + } + int w = iconImage.getWidth(client); + int h = iconImage.getHeight(client); + + if (bkgnd == null || bkgnd.getWidth(client) != w || + bkgnd.getHeight(client) != h) { + bkgnd = client.createImage(w, h); + } + bkgnd.getGraphics().drawImage(backBuffer, -x, -y, client); + backGC.drawImage(iconImage, x, y, client); + //System.out.println("Draw at "+Integer.toString(x)+", "+ + // Integer.toString(y)); + return true; + } + + public final void erase(pclient client, Graphics backGC) { + if (ico != -1) { + //System.out.println("Erase at "+Integer.toString(x)+", "+ + // Integer.toString(y)); + backGC.drawImage(bkgnd, x, y, client); + } + } + } + + // Playfield + + class Playfield { + public static final int TASKBAR_HEIGHT = 48; + + public pclient client; + public int pfwidth, pfheight; + public Image backBuffer; + public Sprite[] sprites = new Sprite[0]; + public int numSprites = 0; + public byte[] pendingBuffer = new byte[SocketDisplayer.UDP_BUF_SIZE]; + public int pendingBufOfs = 0; + public int pendingBufLen = 0; + public byte[] spriteData = new byte[0]; + public int validDataLen = 0; + public Image tbCache; + public AudioClip[] samples = new AudioClip[0]; + public int[] playingSounds = new int[0]; + + Playfield(pclient aclient, int width, int height, Color bkgnd) { + client = aclient; + pfwidth = width; + pfheight = height; + backBuffer = createImage(width, height); + Graphics backGC = backBuffer.getGraphics(); + backGC.setColor(bkgnd); + backGC.fillRect(0, 0, width, height); + backGC.dispose(); + client.resize(width, height); + client.setBackground(bkgnd); + + int[] pixelData = new int[32*TASKBAR_HEIGHT]; + int target = 0; + for (int y=0; y= samples.length) { + AudioClip[] newclip = new AudioClip[key+5]; + System.arraycopy(samples, 0, newclip, 0, samples.length); + samples = newclip; + } + if (samples[key] == null) { + String filename = "sample.wav?code=" + + Integer.toString(key); + samples[key] = getAudioClip(getCodeBase(), filename); + } + else if (playingSounds.length > key && + playingSounds[key] > 0) { + currentSounds[key] = playingSounds[key] - 1; + } + else { + samples[key].play(); + currentSounds[key] = 4; + } + } + base += 6; + } + playingSounds = currentSounds; + pendingBufOfs = base; + pendingBufLen = buflen - base; + return old; + } + + public synchronized int fetchSprites() { + int valid; + int count = (pendingBufLen < validDataLen) ? pendingBufLen + : validDataLen; + + for (valid=0; valid spriteData.length) { + spriteData = new byte[pendingBufLen+90]; + valid = 0; + } + System.arraycopy(pendingBuffer, pendingBufOfs+valid, + spriteData, valid, pendingBufLen-valid); + return pendingBufLen; + } + + public void paint(Graphics g) { + int buflen = fetchSprites(); + byte[] buffer = spriteData; + int count = validDataLen / 6; + int base = count * 6; + int nspr = buflen / 6; + Image tback; + + if (nspr > sprites.length) { + Sprite[] newspr = new Sprite[nspr+15]; + System.arraycopy(sprites, 0, newspr, 0, sprites.length); + for (int i=sprites.length; i=count; i--) { + sprites[i].erase(client, backGC); + } + + // draw new sprites + validDataLen = pendingBufLen; + while (count < nspr) { + Sprite s = sprites[count++]; + int x = buffer[base+1]; + s.x = (x & 0xFF) | (((int) buffer[base ]) << 8); + int y = buffer[base+3]; + s.y = (y & 0xFF) | (((int) buffer[base+2]) << 8); + int ico = buffer[base+5]; + s.ico = (ico & 0xFF) | (((int) buffer[base+4]) << 8); + if (!s.draw(client, backBuffer, backGC)) { + if (base < validDataLen) + validDataLen = base; + //System.out.println(Integer.toString(s.x)+';'+ + // Integer.toString(s.y)+';'+ + // Integer.toString(s.ico)); + } + base += 6; + } + numSprites = count; + + if (client.taskbarmode) + tback = paintTaskbar(backGC); + else + tback = null; + + g.drawImage(backBuffer, 0, 0, client); + + if (tback != null) + eraseTaskbar(backGC, tback); + backGC.dispose(); + } + + public Image paintTaskbar(Graphics g) { + boolean animated = false; + int y0 = pfheight - TASKBAR_HEIGHT; + Image bkgnd = client.createImage(pfwidth, TASKBAR_HEIGHT); + bkgnd.getGraphics().drawImage(backBuffer, 0, -y0, client); + for (int i=0; i 0) { + int index = (int)(f*icons.length); + ico = icons[index % icons.length]; + animated = true; + } + y = y0 + (TASKBAR_HEIGHT-h)/2; + } + } + p.xmin = x; + p.xmax = x + w; + g.drawImage(ico, x, y, client); + } + + if (animated) + repaint(50); + return bkgnd; + } + + public void eraseTaskbar(Graphics g, Image bkgnd) { + int y0 = pfheight - TASKBAR_HEIGHT; + g.drawImage(bkgnd, 0, y0, client); + } + } + + // Socket listener + + class SocketListener extends Thread { + + public pclient client; + public Socket socket; + public InputStream socketInput; + public OutputStream socketOutput; + + public static final String MSG_WELCOME = "Welcome to gamesrv.py(3) !\n"; + public static final byte MSG_DEF_PLAYFIELD = (byte) 'p'; + public static final byte MSG_DEF_KEY = (byte) 'k'; + public static final byte MSG_DEF_ICON = (byte) 'r'; + public static final byte MSG_DEF_BITMAP = (byte) 'm'; + public static final byte MSG_DEF_SAMPLE = (byte) 'w'; + public static final byte MSG_DEF_MUSIC = (byte) 'z'; + public static final byte MSG_PLAY_MUSIC = (byte) 'Z'; + public static final byte MSG_FADEOUT = (byte) 'f'; + public static final byte MSG_PLAYER_JOIN = (byte) '+'; + public static final byte MSG_PLAYER_KILL = (byte) '-'; + public static final byte MSG_PLAYER_ICON = (byte) 'i'; + public static final byte MSG_PING = (byte) 'g'; + public static final byte MSG_PONG = (byte) 'G'; + public static final byte MSG_INLINE_FRAME = (byte) '\\'; + + public static final byte CMSG_KEY = (byte) 'k'; + public static final byte CMSG_ADD_PLAYER = (byte) '+'; + public static final byte CMSG_REMOVE_PLAYER= (byte) '-'; + public static final byte CMSG_UDP_PORT = (byte) '<'; + public static final byte CMSG_ENABLE_SOUND = (byte) 's'; + public static final byte CMSG_ENABLE_MUSIC = (byte) 'm'; + public static final byte CMSG_PING = (byte) 'g'; + public static final byte CMSG_PONG = (byte) 'G'; + public static final byte CMSG_PLAYER_NAME = (byte) 'n'; + + public void connectionClosed() throws IOException { + throw new IOException("connection closed"); + } + + public void protocolError() throws IOException { + throw new IOException("protocol error"); + } + + SocketListener(pclient aclient, Socket asocket) throws IOException { + setDaemon(true); + byte[] msgWelcome = MSG_WELCOME.getBytes("UTF8"); + + client = aclient; + socket = asocket; + socketInput = socket.getInputStream(); + socketOutput = socket.getOutputStream(); + + for (int i=0; i> 24); + n = value >> 16; + buffer[p++] = (byte)(((n&0x80) == 0) ? n&0x7F : n|0xFFFFFF80); + n = value >> 8; + buffer[p++] = (byte)(((n&0x80) == 0) ? n&0x7F : n|0xFFFFFF80); + n = value; + buffer[p++] = (byte)(((n&0x80) == 0) ? n&0x7F : n|0xFFFFFF80); + } + if (lastarg != null) { + for (int i=0; i= end) return -1; + byte msgcode = buffer[base++]; + int[] args = new int[typecodes]; + int repeatcount = 0; + int nargs = 0; + for (int i=0; i end) + return -1; + args[nargs++] = ((int) buffer[base++]) & 0xFF; + } + else if (c == (byte) 'l') { + if (base+4 > end) + return -1; + int n4 = buffer[base++]; + int n3 = buffer[base++]; n3 &= 0xFF; + int n2 = buffer[base++]; n2 &= 0xFF; + int n1 = buffer[base++]; n1 &= 0xFF; + int value = n1 | (n2<<8) | (n3<<16) | (n4<<24); + //System.out.println(n4); + //System.out.println(n3); + //System.out.println(n2); + //System.out.println(n1); + //System.out.println(value); + //System.out.println(); + args[nargs++] = value; + } + else if ((byte) '0' <= c && c <= (byte) '9') { + repeatcount = repeatcount*10 + (c - (byte) '0'); + } + else if (c == (byte) 's') { + if (base+repeatcount > end) + return -1; + args[nargs++] = base; + args[nargs++] = repeatcount; + base += repeatcount; + repeatcount = 0; + } + else + protocolError(); + } + + //System.out.print("Message "); + //System.out.print((char) msgcode); + //for (int i=0; i 3) ? args[3] : -1; + InputStream st = decompresser(buffer, dataofs, datalen); + client.setBitmap(bmpcode, new Bitmap(client, st, colorkey)); + break; + } + case MSG_PLAYER_ICON: { + int pid = args[0]; + int icocode = args[1]; + Player p = client.getPlayer(pid); + p.icon = client.getIcon(icocode); + break; + } + case MSG_PING: { + buffer[ofs+1+typecodes] = CMSG_PONG; + sendData(buffer, ofs, base-ofs); + if (nargs > 0 && !client.udpovertcp) { + int udpkbytes = args[0]; + /* switch to udp_over_tcp if the udp socket didn't + receive at least 60% of the packets sent by the server, + or if the socketdisplayer thread died */ + if (sockdisplayer != null && !sockdisplayer.isAlive()) { + showStatus("routing UDP traffic over TCP (no UDP socket)"); + client.start_udp_over_tcp(); + } + else if (udpkbytes * 1024.0 * 0.60 > client.udpbytecounter) { + client.udpsock_low += 1; + if (client.udpsock_low >= 4) { + double inp =client.udpbytecounter/(udpkbytes*1024.0); + int loss = (int)(100.0*(1.0-inp)); + showStatus("routing UDP traffic over TCP (" + + Integer.toString(loss) + + "% packet loss)"); + client.start_udp_over_tcp(); + } + } + else + client.udpsock_low = 0; + } + break; + } + case MSG_PONG: { + if (!client.taskbarfree && !client.taskbarmode) { + client.taskbarfree = true; + client.setTaskbar(true); + } + break; + } + case MSG_INLINE_FRAME: { + if (client.uinflater != null) { + int dataofs = args[0]; + int datalen = args[1]; + int len; + byte[] pkt = client.uinflater_buffer; + client.uinflater.setInput(buffer, dataofs, datalen); + try { + len = client.uinflater.inflate(pkt); + } + catch (DataFormatException e) { + len = 0; + } + Playfield pf = client.playfield; + if (len > 0 && pf != null) { + client.uinflater_buffer = pf.setSprites(pkt, len); + client.repaint(); + } + } + break; + } + default: { + System.err.println("Note: unknown message " + + Byte.toString(msgcode)); + break; + } + } + return base; + } + + public void run() { + try { + byte[] buffer = new byte[0xC000]; + int begin = 0; + int end = 0; + try { + while (true) { + if (end + 0x6000 > buffer.length) { + // compact buffer + byte[] newbuf; + end = end-begin; + if (end + 0x8000 > buffer.length) + newbuf = new byte[end + 0x8000]; + else + newbuf = buffer; + System.arraycopy(buffer, begin, newbuf, 0, end); + begin = 0; + buffer = newbuf; + } + int count = socketInput.read(buffer, end, 0x6000); + if (isInterrupted()) + break; + if (count <= 0) + connectionClosed(); + end += count; + while ((count=decodeMessage(buffer, begin, end)) >= 0) { + begin = count; + } + } + } + catch (InterruptedIOException e) { + } + socket.close(); + } + catch (IOException e) { + client.debug(e); + } + } + } + + // UDP Socket messages + + class SocketDisplayer extends Thread { + public static final int UDP_BUF_SIZE = 0x10000; + + public pclient client; + public DatagramSocket socket; + + SocketDisplayer(pclient aclient) { + setDaemon(true); + client = aclient; + } + + public void run() { + /* This thread may die early, typically because of JVM + security restrictions. */ + byte[] buffer = new byte[UDP_BUF_SIZE]; + DatagramPacket pkt = new DatagramPacket(buffer, UDP_BUF_SIZE); + try { + { + socket = new DatagramSocket(); + int[] args = new int[1]; + args[0] = socket.getLocalPort(); + client.socklistener.sendMessage(SocketListener.CMSG_UDP_PORT, + args); + } + try { + while (true) { + socket.receive(pkt); + if (isInterrupted()) + break; + client.udpbytecounter += (double) pkt.getLength(); + Playfield pf = client.playfield; + if (pf != null) { + pkt.setData(pf.setSprites(pkt.getData(), + pkt.getLength())); + pkt.setLength(UDP_BUF_SIZE); + client.repaint(); + } + } + } + catch (InterruptedIOException e) { + } + socket.close(); + } + catch (IOException e) { + client.debug(e); + } + } + } + + // Applet methods + + public SocketListener socklistener = null; + public SocketDisplayer sockdisplayer = null; + public Playfield playfield = null; + + public void init() { + try { + Socket link; + String param; + + String gamesrv = getParameter("gamesrv"); + if (gamesrv == null) { + gamesrv = getDocumentBase().getHost(); + } + param = getParameter("gameport"); + if (param != null) { + // direct TCP connexion to the game server + link = new Socket(gamesrv, Integer.parseInt(param)); + } + else { + // UCP query + param = getParameter("port"); + int port = (param != null) ? Integer.parseInt(param) : defaultPort; + link = pickHost(gamesrv, port); + } + socklistener = new SocketListener(this, link); + socklistener.start(); + } + catch (IOException e) { + debug(e); + } + } + + public void destroy() { + if (socklistener != null) { + socklistener.interrupt(); + socklistener = null; + } + } + + public void start() { + enableEvents(AWTEvent.KEY_EVENT_MASK | + AWTEvent.MOUSE_EVENT_MASK | + AWTEvent.MOUSE_MOTION_EVENT_MASK); + if (socklistener != null) { + sockdisplayer = new SocketDisplayer(this); + sockdisplayer.start(); + } + } + + public void stop() { + if (sockdisplayer != null) { + sockdisplayer.interrupt(); + sockdisplayer = null; + } + } + + public void update(Graphics g) { + paint(g); + } + + public void paint(Graphics g) { + Playfield pf = playfield; + if (pf != null) { + pf.paint(g); + } + else { + int appWidth = getSize().width; + int appHeight = getSize().height; + g.clearRect(0, 0, appWidth, appHeight); + } + } + + protected void processKeyEvent(KeyEvent e) { + int num; + byte[] msg; + e.consume(); + switch (e.getID()) { + case KeyEvent.KEY_PRESSED: + num = e.getKeyCode(); + break; + case KeyEvent.KEY_RELEASED: + num = -e.getKeyCode(); + break; + default: + return; + } + msg = (byte[]) keycodes.get(new Integer(num)); + if (msg != null && socklistener != null) { + Player p = getPlayer(msg[0]); + if (p.local) { + try { + socklistener.sendData(msg, 1, msg.length-1); + } + catch (IOException ioe) { + debug(ioe); + } + return; + } + } + if (keydefinition_k != null && e.getID() == KeyEvent.KEY_PRESSED) + defineKey(num); + } + + public void nextKey() { + KeyName k = keydefinition_k; + if (k == null) + k = keys; + else + k = k.next; + while (k != null && k.keyname.charAt(0) == '-') { + k.newkeycode = 0; + k = k.next; + } + keydefinition_k = k; + } + + public void defineKey(int num) { + KeyName k; + for (k=keys; k!=keydefinition_k; k=k.next) + if (k.newkeycode == num) + return; + k.newkeycode = num; + nextKey(); + if (keydefinition_k == null) { + if (socklistener != null) { + try { + byte[] buffer; + int[] args = new int[1]; + args[0] = keydefinition_pid; + socklistener.sendMessage(SocketListener.CMSG_ADD_PLAYER, + args); + String param = "player" + + Integer.toString(keydefinition_pid); + param = getParameter(param); + if (param != null) { + socklistener.sendMessageEx( + SocketListener.CMSG_PLAYER_NAME, + args, + param); + } + args = new int[2]; + args[0] = keydefinition_pid; + for (k=keys; k!=null; k=k.next) { + if (k.keyname.charAt(0) == '-') { + String test = k.keyname.substring(1); + for (KeyName r=keys; r!=null; r=r.next) + if (r.keyname.equals(test)) + k.newkeycode = -r.newkeycode; + } + args[1] = k.keyid; + buffer = socklistener.codeMessage + (1, SocketListener.CMSG_KEY, args, null); + buffer[0] = (byte) keydefinition_pid; + keycodes.put(new Integer(k.newkeycode), buffer); + } + } + catch (IOException ioe) { + debug(ioe); + } + } + } + repaint(); + } + + protected void processMouseMotionEvent(MouseEvent e) { + Playfield pf = playfield; + if (pf != null) + setTaskbar(e.getY() >= pf.pfheight - pf.TASKBAR_HEIGHT); + e.consume(); + } + + protected void processMouseEvent(MouseEvent e) { + if (e.getID() != MouseEvent.MOUSE_PRESSED) { + e.consume(); + return; + } + requestFocus(); + keydefinition_k = null; + Playfield pf = playfield; + if (pf != null && e.getY() >= pf.pfheight - pf.TASKBAR_HEIGHT) { + int x = e.getX(); + for (Player p=firstPlayer(); p!=null; p=nextPlayer(p)) + if (p.xmin <= x && x < p.xmax) { + if (p.local) { + if (socklistener != null) { + try { + int[] args = new int[1]; + args[0] = p.pid; + socklistener.sendMessage + (SocketListener.CMSG_REMOVE_PLAYER, args); + } + catch (IOException ioe) { + debug(ioe); + } + } + } + else { + keydefinition_pid = p.pid; + nextKey(); + } + break; + } + } + e.consume(); + repaint(); + } + + // UDP-over-TCP + public double udpbytecounter = 0.0; + public int udpsock_low = 0; + public boolean udpovertcp = false; + public Inflater uinflater = null; + public byte[] uinflater_buffer = null; + + public void start_udp_over_tcp() + { + udpovertcp = true; + int[] args = new int[1]; + args[0] = 0; + try { + socklistener.sendMessage(SocketListener.CMSG_UDP_PORT, args); + } + catch (IOException e) { + return; + } + uinflater_buffer = new byte[SocketDisplayer.UDP_BUF_SIZE]; + uinflater = new Inflater(); + if (sockdisplayer != null) { + sockdisplayer.interrupt(); + sockdisplayer = null; + } + } + +// // ImageObserver interface + +// public boolean imageUpdate(Image img, +// int infoflags, +// int x, +// int y, +// int width, +// int height) { +// return false; +// } +} -- cgit v1.2.3