/*
* $Id$
*
* Copyright (c) 2008 by Michael Kiefte
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Library General Public
* License (LGPL) as published by the Free Software Foundation.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Library General Public License for more details.
*
* You should have received a copy of the GNU Library General Public
* License along with this library; if not, copies are available
* at http://www.opensource.org.
*/
package VASSAL.tools.imports.adc2;
import java.awt.AlphaComposite;
import java.awt.Dimension;
import java.awt.Graphics2D;
import java.awt.Rectangle;
import java.awt.image.BandCombineOp;
import java.awt.image.BufferedImage;
import java.io.BufferedInputStream;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import javax.imageio.ImageIO;
import VASSAL.build.GameModule;
import VASSAL.tools.ArrayUtils;
import VASSAL.tools.filechooser.BMPFileFilter;
import VASSAL.tools.imports.FileFormatException;
import VASSAL.tools.imports.ImportAction;
import VASSAL.tools.imports.Importer;
import VASSAL.tools.io.IOUtils;
/**
* ADC2 game piece and terrain symbols.
*
* @author Michael Kiefte
*
*/
public class SymbolSet extends Importer{
private static final int OLD_SYMBOL_SET_FORMAT = 0xFD;
/**
* Shape of the terrain elements.
*/
enum Shape { // an enum is overkill here...
SQUARE, HEX
}
/**
* Contains all of the information for a single game piece or terrain icon
*/
class SymbolData {
/**
* Shared bitmap of all symbols in either the terrain or game piece set.
* Cannot be static as there are three different possible shared images
* corresponding to game pieces, terrain features, and bitmasks.
*/
private final BufferedImage bitmap;
/**
* Actual name of image file for this symbol in archive. May not be the same as the name
* provided in the configuration file if duplicates exist.
*/
private String fileName;
/**
* Actual image which is lazily generated on request.
*/
private BufferedImage img;
/**
* Prevents infinite loops when applying masks to symbol images.
*/
private final boolean isMask;
/**
* Base 1 index into SymbolData array of masks. 0 means no mask.
*/
private int maskIndex;
/**
* Actual name given in the configuration file--not the archive file name
* as duplicates are permitted in ADC2.
*/
private String name;
/**
* Rectangle in shared bitmap <code>img</code>.
*/
private Rectangle rect;
/**
* <code>Boolean.TRUE</code> if the image is completely transparent (invisible)
* and <code>null</code> if it has not yet been checked.
*/
private Boolean transparent;
SymbolData(BufferedImage bitmap, boolean isMask) {
this.bitmap = bitmap;
this.isMask = isMask;
}
/**
* Get the mask associated with this symbol. Returns <code>null</code> it this
* is a mask, if it is from an older version file, or if there is no mask.
*/
protected SymbolData getMask() {
if (ignoreMask || isMask)
return null;
// mask index is base 1
else if (maskIndex > 0 && maskIndex <= maskData.length)
return maskData[maskIndex - 1];
else
return null;
}
/**
* Read symbol data from configuration file.
*
* @return <code>this</code>, so that methods may be chained in series
* following <code>read</code>.
*/
protected SymbolData read(DataInputStream in) throws IOException {
name = readNullTerminatedString(in);
// Have to read in all the zoom level sizes for the first one
// so that zooming in VASSAL can approximate the zoom behaviour of the
// imported module.
boolean readAllZoomLevels = false;
if (mapBoardSymbolSize == null) {
mapBoardSymbolSize = new int[3];
readAllZoomLevels = true;
}
// Alternate indexing style (to be implemented):
// if (header == -3)
// pict.maskIndex = in.readUnsignedByte();
// else
maskIndex = header == OLD_SYMBOL_SET_FORMAT ? in.readUnsignedByte() : ADC2Utils.readBase250Word(in);
for (int i = 0; i < 3; ++i) {
int x1 = in.readInt();
int y1 = in.readInt();
int x2 = in.readInt();
int y2 = in.readInt();
int width = x2 - x1 + 1;
int height = y2 - y1 + 1;
if (readAllZoomLevels)
mapBoardSymbolSize[i] = height; // or width--they should be square
if (i == zoomLevel)
rect = new Rectangle(x1, y1, width, height);
}
return this;
}
/**
* Returns the archive file name corresponding to the image for this
* symbol. If called, will actually write the image to the archive in
* order to get the file name itself.
*
* @throws IOException if unable to read the image files for this symbol.
*/
String getFileName() throws IOException {
if (fileName == null)
writeToArchive();
return fileName;
}
/**
* Returns true if all alpha values are zero.
*/
boolean isTransparent() {
if (transparent == null) {
BufferedImage image = getImage();
transparent = Boolean.TRUE;
search:
for (int i = 0; i < image.getWidth(); ++i) {
for (int j = 0; j < image.getHeight(); ++j) {
if (image.getRGB(i, j) != 0) {
transparent = Boolean.FALSE;
break search;
}
}
}
}
return transparent.booleanValue();
}
/**
* Get image corresponding to this symbol. Generates the image and applies
* optional mask if not already done so.
* @param rect2 width and height are taken from this for otherwise invalid masks
*/
private BufferedImage getImage(Rectangle rect2) {
if (img == null) {
if ( isMask && (rect.width <= 0 || rect.height <= 0
|| rect.width+rect.x > bitmap.getWidth()
|| rect.height+rect.y > bitmap.getHeight() )) {
// Images with invalid masks appear to be completely transparent.
// This is a hassle generating new ones all the time, but there's nothing
// to say that the real mask can't be different sizes at every call,
// and anything else seems like overkill -- so this is an ugly kludge.
// Hopefully, this crime against nature doesn't happen very often.
return new BufferedImage(rect2.width, rect2.height, BufferedImage.TYPE_INT_ARGB);
}
img = bitmap.getSubimage(rect.x, rect.y, rect.width, rect.height);
if (getMask() != null) {
final BufferedImage bi = new BufferedImage(rect.width, rect.height, BufferedImage.TYPE_INT_ARGB);
final Graphics2D g = bi.createGraphics();
g.drawImage(img, null, 0, 0);
g.setComposite(AlphaComposite.DstAtop);
g.drawImage(getMask().getImage(rect), null, 0, 0);
img = bi;
}
}
return img;
}
BufferedImage getImage() {
return getImage(rect);
}
/**
* Write symbol image to archive and return archive file name. Will only
* write to archive once.
*/
protected void writeToArchive() throws IOException {
// only gets written once. if filename is not null, then we've
// already been written
if (fileName == null) { // this condition is really just a failsafe check
fileName = getUniqueImageFileName(name);
final ByteArrayOutputStream out = new ByteArrayOutputStream();
ImageIO.write(getImage(), "png", out);
GameModule.getGameModule().getArchiveWriter().addImage(fileName, out.toByteArray());
}
}
}
// Permute negative of red band (doesn't matter which colour) to alpha band.
// masks are originally black where alpha should be 1.0 and white where
// alpha should be 0.0.
private final static float[][] THREE_BAND_MATRIX = {
{ 0.0f, 0.0f, 0.0f, 0.0f },
{ 0.0f, 0.0f, 0.0f, 0.0f },
{ 0.0f, 0.0f, 0.0f, 0.0f },
{ -1.0f, 0.0f, 0.0f, 255.0f }};
private final static float[][] ONE_BAND_MATRIX = {
{ 0.0f, 0.0f },
{ 0.0f, 0.0f },
{ 0.0f, 0.0f },
{ -255.0f, 255.0f }};
/**
* Convert a black-and-white bitmap to a mask image.
*/
private static BufferedImage generateAlphaMask(BufferedImage img) {
final BandCombineOp op;
if (img.getSampleModel().getNumBands() == 1)
op = new BandCombineOp(ONE_BAND_MATRIX, null);
else
op = new BandCombineOp(THREE_BAND_MATRIX, null);
final BufferedImage bi = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB);
op.filter(img.getRaster(), bi.getRaster());
return bi;
}
/**
* Unit symbols
*/
private SymbolData[] gamePieceData;
/**
* Terrain symbols
*/
private SymbolData[] mapBoardData;
/**
* Symbol size for all three zoom levels. Used to set zoom scale in VASSAL.
*/
private int[] mapBoardSymbolSize;
/**
* Mask symbols. Only used by SymbolData when requesting a mask index.
* Doesn't get used at all in Version I sets (where <code>ignoreMask</code> is true).
*/
private SymbolData[] maskData = null;
/**
* Hex or square.
*/
private Shape symbolShape;
/**
* Zoom level to import
*/
private static final int zoomLevel = 2;
/**
* Ignore mask despite specified mask indeces. True for older configuration files.
*/
private boolean ignoreMask;
private boolean isCardSet;
private int header;
BufferedImage underlay;
/**
* Read symbol images based on basename and suffix. Bitmap filenames are of the form
* <tt>name + "-CN.bmp"</tt> where C is the specified suffix ('M' for masks; 'U' for
* game pieces; 'T' for terrain symbols) and N is the base-1 zoom level.
*
* @param filename base file name. Should be the same as the base file name of the
* symbol set file.
* @param suffix single character suffix
*/
BufferedImage loadSymbolImage(String filename, char suffix, boolean queryIfNotFound) throws IOException {
String fn;
if (suffix == '\0')
fn = filename + (zoomLevel + 1) + ".bmp";
else
fn = filename + '-' + suffix + (zoomLevel + 1) + ".bmp";
File f = action.getCaseInsensitiveFile(new File(fn), null, queryIfNotFound, new BMPFileFilter());
if (f == null && queryIfNotFound) {
throw new FileNotFoundException("Missing bitmap file: " + fn);
}
else if (f == null) {
return null;
}
else {
return ImageIO.read(f);
}
}
BufferedImage loadSymbolImage(String filename, char suffix) throws IOException {
return loadSymbolImage(filename, suffix, true);
}
/**
* Read dimensions from input file. The dimensions must occur in triplets in the
* configuration file--each one corresponding to a zoom level.
* Only the third dimension, corresponding to zoom level 3, is returned.
*/
Dimension readDimension(DataInputStream in) throws IOException {
Dimension d = null;
for (int i = 0; i < 3; ++i) {
int width = in.readInt();
int height = in.readInt();
if (zoomLevel == i)
d = new Dimension(width, height);
}
return d;
}
/**
* Returns the <code>SymbolData</code> corresponding to the game piece at
* the specified index.
*
* @return <code>null</code> if the index is out of bounds.
*/
SymbolData getGamePiece(int index) {
if (index >= 0 && index < gamePieceData.length)
return gamePieceData[index];
else
return null;
}
/**
* Returns the <code>SymbolData</code> corresponding to the terrain symbol at
* the specified index.
*
* @return <code>null</code> if the index is out of bounds.
*/
SymbolData getMapBoardSymbol(int index) {
if (index >= 0 & index < mapBoardData.length)
return mapBoardData[index];
else
return null;
}
public Dimension getMaxSize(Dimension max) {
if (max == null)
max = new Dimension(0,0);
for (SymbolData piece : gamePieceData) {
BufferedImage im = piece.getImage();
if (im.getWidth() > max.width)
max.width = im.getWidth();
if (im.getHeight() > max.height)
max.height = im.getHeight();
}
return max;
}
public Dimension getMaxSize() {
return getMaxSize(null);
}
/**
* @return The most frequently occuring dimension for game pieces in this module.
*/
public Dimension getModalSize() {
final HashMap<Dimension,Integer> histogram =
new HashMap<Dimension,Integer>();
for (SymbolData piece : gamePieceData) {
final BufferedImage im = piece.getImage();
final Dimension d = new Dimension(im.getWidth(), im.getHeight());
final Integer i = histogram.get(d);
histogram.put(d, i == null ? 1 : i+1);
}
int max = 0;
final Dimension maxDim = new Dimension(0,0);
for (Map.Entry<Dimension,Integer> e : histogram.entrySet()) {
final Dimension d = e.getKey();
final int n = e.getValue();
if (n > max) {
max = n;
maxDim.height = d.height;
maxDim.width = d.width;
}
}
return maxDim;
}
/**
* @return Map board symbol size corresponding to the current zoom level.
*/
int getMapBoardSymbolSize() {
return mapBoardSymbolSize[zoomLevel];
}
/**
* @return Map board symbol size corresponding to the specified zoom level
*/
double getZoomFactor(int zoomLevel) {
return (double) mapBoardSymbolSize[zoomLevel] / (double) mapBoardSymbolSize[SymbolSet.zoomLevel];
}
/**
* @return Hex or square?
*/
Shape getMapBoardSymbolShape() {
return symbolShape;
}
/**
* Read a card set from the specified file.
* @throws IOException
*/
void importCardSet(ImportAction a, File f) throws IOException {
isCardSet = true;
importFile(a, f);
}
/**
* Read a symbol set from the specified file.
*/
protected void load(File f) throws IOException {
super.load(f);
DataInputStream in = null;
try {
in = new DataInputStream(new BufferedInputStream(new FileInputStream(f)));
// if header is 0xFD, then mask indeces are one-byte long. Otherwise, if
// the header is anything else greater than 0xFA, then mask indeces are base-250
// two-byte words.
header = in.readUnsignedByte();
if (header < 0xFA)
throw new FileFormatException("Invalid Symbol Set Header");
// comletely overridden by the map file
/* int orientation = */ in.readByte(); // 1=vertical; 2=horizontal; 3=grid
int mapStyle = in.readByte(); // 0=grid; all other values=hex
switch (mapStyle) {
case 1:
symbolShape = Shape.HEX;
break;
default:
symbolShape = Shape.SQUARE;
}
int symSetVersion = in.readByte(); // 1=version 1
switch (symSetVersion) {
case 0:
ignoreMask = false;
break;
default:
ignoreMask = true;
}
if (isCardSet)
ignoreMask = true;
// bitmap dimensions are completely ignored
int nMapBoardSymbols = ADC2Utils.readBase250Word(in);
mapBoardData = new SymbolData[nMapBoardSymbols];
/* terrainBitmapDims = */ readDimension(in);
int nGamePieceSymbols = ADC2Utils.readBase250Word(in);
gamePieceData = new SymbolData[nGamePieceSymbols];
/* unitBitmapDims = */ readDimension(in);
int nMasks = ADC2Utils.readBase250Word(in);
/* maskBitmapDims = */ readDimension(in);
maskData = new SymbolData[nMasks];
String baseName = stripExtension(f.getPath());
// load images
BufferedImage mapBoardImages = null;
if (!isCardSet)
mapBoardImages = loadSymbolImage(baseName, 't');
for (int i = 0; i < nMapBoardSymbols; ++i) {
mapBoardData[i] = new SymbolData(mapBoardImages, false).read(in);
// check for size consistency. Not sure what to do if they're not
// all the same size or not square
if (!isCardSet) {
if (mapBoardData[i].rect.height != mapBoardData[0].rect.height
|| mapBoardData[i].rect.width != mapBoardData[0].rect.width)
throw new FileFormatException("Map board image dimensions are inconsistent");
if (mapBoardData[i].rect.width != mapBoardData[i].rect.height)
throw new FileFormatException("Map board image dimensions are not square");
}
}
BufferedImage gamePieceImages;
if (isCardSet) {
gamePieceImages = loadSymbolImage(baseName);
}
else {
gamePieceImages = loadSymbolImage(baseName, 'u');
}
for (int i = 0; i < nGamePieceSymbols; ++i) {
gamePieceData[i] = new SymbolData(gamePieceImages, false).read(in);
}
if (!ignoreMask) {
BufferedImage maskImages = loadSymbolImage(baseName, 'm');
// convert binary bitmap to RGBA alpha mask
maskImages = generateAlphaMask(maskImages);
for (int i = 0; i < nMasks; ++i)
maskData[i] = new SymbolData(maskImages, true).read(in);
}
in.close();
/* See if there is a single-image underlay for the map. */
underlay = loadSymbolImage(baseName, 'z', false);
}
finally {
IOUtils.closeQuietly(in);
}
readPermutationFile(f);
}
private BufferedImage loadSymbolImage(String string) throws IOException {
return loadSymbolImage(string, '\0');
}
/**
* Read an SDX file if one exists. This is a list of image indeces starting with terrain
* separated by newlines. Only piece images are actually permuted.
*
* @param f - Set file.
* @throws IOException
*/
protected void readPermutationFile(File f) throws IOException {
File sdx = new File(forceExtension(f.getPath(), "sdx"));
sdx = action.getCaseInsensitiveFile(sdx, f, false, null);
if (sdx != null) { // must reorder image indeces
BufferedReader input = null;
try {
input = new BufferedReader(new FileReader(sdx));
final SymbolData[] pieces = ArrayUtils.copyOf(gamePieceData);
String line = null;
try {
for (int i = 0; i < mapBoardData.length; ++i) {
line = input.readLine();
}
for (int i = 0; i < pieces.length; ++i) {
line = input.readLine();
int idx = Integer.parseInt(line);
pieces[i] = gamePieceData[idx-1];
}
}
catch (EOFException e) {}
catch (ArrayIndexOutOfBoundsException e) {
throw new FileFormatException("SDX file has out-of-bounds index \"" + line + "\".");
}
catch (NumberFormatException e) {
throw new FileFormatException("SDX file has invalid index \"" + line + "\".");
}
finally {
gamePieceData = pieces;
}
input.close();
}
finally {
IOUtils.closeQuietly(input);
}
}
}
/**
* Write all of the game pieces to the archive. Mainly for testing or if only
* the symbol set is imported.
*/
public void writeToArchive() throws IOException {
for (SymbolData piece : gamePieceData)
piece.writeToArchive();
// for testing purposes only
// for (SymbolData terrain : mapBoardData)
// terrain.writeToArchive();
}
@Override
public boolean isValidImportFile(File f) throws IOException {
DataInputStream in = null;
try {
in = new DataInputStream(new FileInputStream(f));
boolean valid = in.readUnsignedByte() >= 0xFA;
in.close();
return valid;
}
finally {
IOUtils.closeQuietly(in);
}
}
}